Example: code-block
Code block with syntax highlighting and a Mermaid diagram preview.
Install this example with
shadcn: npx shadcn@latest add @prosekit/react-example-code-blocknpx shadcn@latest add @prosekit/lit-example-code-blocknpx shadcn@latest add @prosekit/preact-example-code-blocknpx shadcn@latest add @prosekit/solid-example-code-blocknpx shadcn@latest add @prosekit/svelte-example-code-blocknpx shadcn@latest add @prosekit/vue-example-code-block- examples/code-block/editor.tsx
- examples/code-block/extension.ts
- examples/code-block/index.ts
- sample/sample-doc-code-block.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<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 { defineBaseKeymap, union } from 'prosekit/core'
import { defineCodeBlock, defineCodeBlockPreviewPlugin, defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineDoc } from 'prosekit/extensions/doc'
import { defineParagraph } from 'prosekit/extensions/paragraph'
import { defineText } from 'prosekit/extensions/text'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
export function defineExtension() {
return union(
defineBaseKeymap(),
defineDoc(),
defineText(),
defineParagraph(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineCodeBlockPreviewPlugin(),
defineCodeBlockView(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}'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.tsx''use client'
import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { ReactNodeViewProps } from 'prosekit/react'
import { useMemo, useRef } from 'react'
export default function CodeBlockView(props: ReactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language || ''
const hidePreview = props.decorations.some(isCodeBlockPreviewHiddenDecoration)
const preRef = useRef<HTMLElement | null>(null)
const showMermaidPreview = !hidePreview && language === 'mermaid'
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
const focusSource = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
const code = props.node.textContent
const mermaidPreview = useMemo(() => {
if (language !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(code, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
}, [code, language])
return (
<>
<div
className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable={false}
data-preview={showMermaidPreview ? '' : undefined}
>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre
ref={(element) => {
props.contentRef(element)
preRef.current = element
}}
className="data-preview:hidden"
data-preview={showMermaidPreview ? '' : undefined}
data-language={language}
></pre>
{showMermaidPreview && (
<div
aria-label="Edit source"
className="block py-2 overflow-auto"
contentEditable={false}
onMouseDown={focusSource}
>
{mermaidPreview.error ? <pre>{mermaidPreview.error.message}</pre> : null}
{mermaidPreview.svg ? <div dangerouslySetInnerHTML={{ __html: mermaidPreview.svg }}></div> : null}
</div>
)}
</>
)
}import type { Extension } from 'prosekit/core'
import { defineReactNodeView, type ReactNodeViewComponent } from 'prosekit/react'
import CodeBlockView from './code-block-view.tsx'
export function defineCodeBlockView(): Extension {
return defineReactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies ReactNodeViewComponent,
})
}'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/index.ts'
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.tsx'export { default as Toolbar } from './toolbar.tsx''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/index.ts'
import { ImageUploadPopover } from '../image-upload-popover/index.ts'
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>
)
}- examples/code-block/editor.ts
- examples/code-block/extension.ts
- examples/code-block/index.ts
- sample/sample-doc-code-block.ts
- sample/sample-uploader.ts
- ui/button/button.ts
- ui/button/index.ts
- ui/code-block-view/code-block-view.ts
- ui/code-block-view/index.ts
- ui/editor-context.ts
- ui/image-upload-popover/image-upload-popover.ts
- ui/image-upload-popover/index.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.ts
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 { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { editorContext } from '../../ui/editor-context.ts'
import { registerLitEditorToolbar } from '../../ui/toolbar/index.ts'
import { defineExtension } from './extension.ts'
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, defaultContent: sampleContent })
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-code-block')) return
customElements.define('lit-editor-example-code-block', LitEditor)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-example-code-block': LitEditor
}
}import { defineBaseKeymap, union } from 'prosekit/core'
import {
defineCodeBlock,
defineCodeBlockPreviewPlugin,
defineCodeBlockShiki,
} from 'prosekit/extensions/code-block'
import { defineDoc } from 'prosekit/extensions/doc'
import { defineParagraph } from 'prosekit/extensions/paragraph'
import { defineText } from 'prosekit/extensions/text'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
export function defineExtension() {
return union(
defineBaseKeymap(),
defineDoc(),
defineText(),
defineParagraph(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineCodeBlockPreviewPlugin(),
defineCodeBlockView(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { LitEditor as ExampleEditor, registerLitEditor } from './editor.ts'import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}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.ts'import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { html, render } from 'lit'
import type { Extension } from 'prosekit/core'
import { defineNodeView } from 'prosekit/core'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import {
isCodeBlockPreviewHiddenDecoration,
shikiBundledLanguagesInfo,
} from 'prosekit/extensions/code-block'
import type { ProseMirrorNode } from 'prosekit/pm/model'
import { TextSelection } from 'prosekit/pm/state'
import type { Decoration, EditorView } from 'prosekit/pm/view'
class CodeBlockNodeView {
dom: HTMLElement
contentDOM: HTMLElement
private node: ProseMirrorNode
private view: EditorView
private getPos: () => number | undefined
private decorations: readonly Decoration[]
private wrapper: HTMLDivElement
private pre: HTMLPreElement
private preview: HTMLDivElement
constructor(
node: ProseMirrorNode,
view: EditorView,
getPos: () => number | undefined,
decorations: readonly Decoration[],
) {
this.node = node
this.view = view
this.getPos = getPos
this.decorations = decorations
const root = document.createElement('div')
root.setAttribute('data-node-view-root', 'true')
this.wrapper = document.createElement('div')
this.wrapper.className = 'relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden'
this.wrapper.setAttribute('contenteditable', 'false')
this.pre = document.createElement('pre')
this.pre.className = 'data-preview:hidden'
this.contentDOM = document.createElement('code')
this.contentDOM.setAttribute('data-node-view-content', 'true')
this.contentDOM.style.whiteSpace = 'inherit'
this.pre.appendChild(this.contentDOM)
this.preview = document.createElement('div')
this.preview.className = 'block py-2 overflow-auto'
this.preview.setAttribute('contenteditable', 'false')
this.preview.setAttribute('aria-label', 'Edit source')
this.preview.addEventListener('mousedown', this.handlePreviewMouseDown)
root.appendChild(this.wrapper)
root.appendChild(this.pre)
this.dom = root
this.sync()
}
private handleChange = (event: Event) => {
const language = (event.target as HTMLSelectElement).value
const pos = this.getPos()
if (typeof pos !== 'number') return
const attrs: CodeBlockAttrs = { ...(this.node.attrs as CodeBlockAttrs), language }
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, attrs))
}
private handlePreviewMouseDown = (event: MouseEvent) => {
event.preventDefault()
const pos = this.getPos()
if (typeof pos !== 'number') return
const selection = TextSelection.near(this.view.state.doc.resolve(pos + 1), 1)
this.view.dispatch(this.view.state.tr.setSelection(selection))
this.view.focus()
this.pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
private sync() {
const language = (this.node.attrs as CodeBlockAttrs).language || ''
const hidePreview = this.decorations.some(isCodeBlockPreviewHiddenDecoration)
const showMermaidPreview = !hidePreview && language === 'mermaid'
render(
html`
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
.value=${language}
@change=${this.handleChange}
>
<option value="">Plain Text</option>
${shikiBundledLanguagesInfo.map(
(info) => html`<option value=${info.id}>${info.name}</option>`,
)}
</select>
`,
this.wrapper,
)
if (language) {
this.pre.setAttribute('data-language', language)
} else {
this.pre.removeAttribute('data-language')
}
if (showMermaidPreview) {
this.wrapper.setAttribute('data-preview', '')
this.pre.setAttribute('data-preview', '')
this.renderPreview()
if (!this.preview.isConnected) {
this.dom.appendChild(this.preview)
}
} else {
this.wrapper.removeAttribute('data-preview')
this.pre.removeAttribute('data-preview')
this.preview.replaceChildren()
if (this.preview.isConnected) {
this.preview.remove()
}
}
}
private renderPreview() {
this.preview.replaceChildren()
try {
const svg = renderMermaidSVG(this.node.textContent, THEMES['tokyo-night'])
const svgWrapper = document.createElement('div')
svgWrapper.innerHTML = svg
this.preview.appendChild(svgWrapper)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const errorPre = document.createElement('pre')
errorPre.textContent = message
this.preview.appendChild(errorPre)
}
}
update(node: ProseMirrorNode, decorations: readonly Decoration[]) {
if (node.type !== this.node.type) return false
this.node = node
this.decorations = decorations
this.sync()
return true
}
destroy() {
this.preview.removeEventListener('mousedown', this.handlePreviewMouseDown)
render(null, this.wrapper)
}
}
export function defineCodeBlockView(): Extension {
return defineNodeView({
name: 'codeBlock',
constructor: (node, view, getPos, decorations) =>
new CodeBlockNodeView(node, view, getPos, decorations),
})
}export { defineCodeBlockView } from './code-block-view.ts'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/index.ts'
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.ts'export { registerLitEditorToolbar } from './toolbar.ts'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/index.ts'
import { editorContext } from '../editor-context.ts'
import { registerLitEditorImageUploadPopover } from '../image-upload-popover/index.ts'
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
}
}- examples/code-block/editor.tsx
- examples/code-block/extension.ts
- examples/code-block/index.ts
- sample/sample-doc-code-block.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<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 { defineBaseKeymap, union } from 'prosekit/core'
import { defineCodeBlock, defineCodeBlockPreviewPlugin, defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineDoc } from 'prosekit/extensions/doc'
import { defineParagraph } from 'prosekit/extensions/paragraph'
import { defineText } from 'prosekit/extensions/text'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
export function defineExtension() {
return union(
defineBaseKeymap(),
defineDoc(),
defineText(),
defineParagraph(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineCodeBlockPreviewPlugin(),
defineCodeBlockView(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}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.tsx'import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import type { JSX } from 'preact'
import { useMemo, useRef } from 'preact/hooks'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { PreactNodeViewProps } from 'prosekit/preact'
export default function CodeBlockView(props: PreactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language || ''
const hidePreview = props.decorations.some(isCodeBlockPreviewHiddenDecoration)
const preRef = useRef<HTMLPreElement | null>(null)
const showMermaidPreview = !hidePreview && language === 'mermaid'
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
const handleChange = (
event: JSX.TargetedEvent<HTMLSelectElement, Event>,
) => {
setLanguage(event.currentTarget.value)
}
const focusSource = (event: JSX.TargetedMouseEvent<HTMLDivElement>) => {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
const code = props.node.textContent
const mermaidPreview = useMemo(() => {
if (language !== 'mermaid') return { svg: null as string | null, error: null as Error | null }
try {
return { svg: renderMermaidSVG(code, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
}, [code, language])
return (
<>
<div
className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable={false}
data-preview={showMermaidPreview ? '' : undefined}
>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={handleChange}
value={language}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre
ref={(element) => {
props.contentRef(element)
preRef.current = element
}}
className="data-preview:hidden"
data-preview={showMermaidPreview ? '' : undefined}
data-language={language}
></pre>
{showMermaidPreview && (
<div
aria-label="Edit source"
className="block py-2 overflow-auto"
contentEditable={false}
onMouseDown={focusSource}
>
{mermaidPreview.error ? <pre>{mermaidPreview.error.message}</pre> : null}
{mermaidPreview.svg
? <div dangerouslySetInnerHTML={{ __html: mermaidPreview.svg }}></div>
: null}
</div>
)}
</>
)
}import type { Extension } from 'prosekit/core'
import { definePreactNodeView, type PreactNodeViewComponent } from 'prosekit/preact'
import CodeBlockView from './code-block-view.tsx'
export function defineCodeBlockView(): Extension {
return definePreactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies PreactNodeViewComponent,
})
}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/index.ts'
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.tsx'export { default as Toolbar } from './toolbar.tsx'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/index.ts'
import { ImageUploadPopover } from '../image-upload-popover/index.ts'
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>
)
}- examples/code-block/editor.tsx
- examples/code-block/extension.ts
- examples/code-block/index.ts
- sample/sample-doc-code-block.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const defaultContent = props.initialContent ?? sampleContent
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
return (
<ProseKit editor={editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<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 { defineBaseKeymap, union } from 'prosekit/core'
import { defineCodeBlock, defineCodeBlockPreviewPlugin, defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineDoc } from 'prosekit/extensions/doc'
import { defineParagraph } from 'prosekit/extensions/paragraph'
import { defineText } from 'prosekit/extensions/text'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
export function defineExtension() {
return union(
defineBaseKeymap(),
defineDoc(),
defineText(),
defineParagraph(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineCodeBlockPreviewPlugin(),
defineCodeBlockView(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}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.tsx'import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { SolidNodeViewProps } from 'prosekit/solid'
import { createMemo, For, Show, type JSX } from 'solid-js'
export default function CodeBlockView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as CodeBlockAttrs
const language = () => attrs().language || ''
const hidePreview = () => props.decorations.some(isCodeBlockPreviewHiddenDecoration)
const showMermaidPreview = () => !hidePreview() && language() === 'mermaid'
let preRef: HTMLPreElement | undefined
const mermaidPreview = createMemo<{ svg: string | null; error: Error | null }>(() => {
if (language() !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(props.node.textContent, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
})
const setLanguage = (lang: string) => {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
const focusSource = (event: MouseEvent) => {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preRef?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
return (
<>
<div
class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable={false}
data-preview={showMermaidPreview() ? '' : undefined}
>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language()}
>
<option value="">Plain Text</option>
<For each={shikiBundledLanguagesInfo}>
{(info) => (
<option value={info.id}>
{info.name}
</option>
)}
</For>
</select>
</div>
<pre
ref={(element) => {
props.contentRef(element)
preRef = element
}}
class="data-preview:hidden"
data-preview={showMermaidPreview() ? '' : undefined}
data-language={language()}
></pre>
<Show when={showMermaidPreview()}>
<div
aria-label="Edit source"
class="block py-2 overflow-auto"
contentEditable={false}
onMouseDown={focusSource}
>
<Show when={mermaidPreview().error}>
<pre>{mermaidPreview().error?.message}</pre>
</Show>
<Show when={mermaidPreview().svg}>
<div innerHTML={mermaidPreview().svg || ''}></div>
</Show>
</div>
</Show>
</>
)
}import type { Extension } from 'prosekit/core'
import { defineSolidNodeView, type SolidNodeViewComponent } from 'prosekit/solid'
import CodeBlockView from './code-block-view.tsx'
export function defineCodeBlockView(): Extension {
return defineSolidNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies SolidNodeViewComponent,
})
}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/index.ts'
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.tsx'export { default as Toolbar } from './toolbar.tsx'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/index.ts'
import { ImageUploadPopover } from '../image-upload-popover/index.ts'
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
export default function Toolbar(props: { uploader?: Uploader<string> }): JSX.Element {
const items = useEditorDerivedValue(getToolbarItems)
return (
<div class="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
<Show when={items().undo}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Undo"
>
<div class="i-lucide-undo-2 size-5 block" />
</Button>
)}
</Show>
<Show when={items().redo}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Redo"
>
<div class="i-lucide-redo-2 size-5 block" />
</Button>
)}
</Show>
<Show when={items().bold}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block" />
</Button>
)}
</Show>
<Show when={items().italic}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block" />
</Button>
)}
</Show>
<Show when={items().underline}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block" />
</Button>
)}
</Show>
<Show when={items().strike}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Strike"
>
<div class="i-lucide-strikethrough size-5 block" />
</Button>
)}
</Show>
<Show when={items().code}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block" />
</Button>
)}
</Show>
<Show when={items().codeBlock}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code Block"
>
<div class="i-lucide-square-code size-5 block" />
</Button>
)}
</Show>
<Show when={items().heading1}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Heading 1"
>
<div class="i-lucide-heading-1 size-5 block" />
</Button>
)}
</Show>
<Show when={items().heading2}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Heading 2"
>
<div class="i-lucide-heading-2 size-5 block" />
</Button>
)}
</Show>
<Show when={items().heading3}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Heading 3"
>
<div class="i-lucide-heading-3 size-5 block" />
</Button>
)}
</Show>
<Show when={items().horizontalRule}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Divider"
>
<div class="i-lucide-minus size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().blockquote}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Blockquote"
>
<div class="i-lucide-text-quote size-5 block" />
</Button>
)}
</Show>
<Show when={items().bulletList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bullet List"
>
<div class="i-lucide-list size-5 block" />
</Button>
)}
</Show>
<Show when={items().orderedList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Ordered List"
>
<div class="i-lucide-list-ordered size-5 block" />
</Button>
)}
</Show>
<Show when={items().taskList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Task List"
>
<div class="i-lucide-list-checks size-5 block" />
</Button>
)}
</Show>
<Show when={items().toggleList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Toggle List"
>
<div class="i-lucide-list-collapse size-5 block" />
</Button>
)}
</Show>
<Show when={items().indentList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Increase indentation"
>
<div class="i-lucide-indent-increase size-5 block" />
</Button>
)}
</Show>
<Show when={items().dedentList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Decrease indentation"
>
<div class="i-lucide-indent-decrease size-5 block" />
</Button>
)}
</Show>
<Show when={props.uploader && items().insertImage}>
{(item) => (
<ImageUploadPopover
uploader={props.uploader!}
disabled={!item().canExec}
tooltip="Insert Image"
>
<div class="i-lucide-image size-5 block" />
</ImageUploadPopover>
)}
</Show>
</div>
)
}- examples/code-block/editor.svelte
- examples/code-block/extension.ts
- examples/code-block/index.ts
- sample/sample-doc-code-block.ts
- ui/button/button.svelte
- ui/button/index.ts
- ui/code-block-view/code-block-view.svelte
- ui/code-block-view/index.ts
- ui/image-upload-popover/image-upload-popover.svelte
- ui/image-upload-popover/index.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.svelte
<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { untrack } from 'svelte'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { defineExtension } from './extension.ts'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineExtension()
const defaultContent = untrack(() => props.initialContent ?? sampleContent)
const editor = createEditor({ extension, defaultContent })
</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 />
<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 { defineBaseKeymap, union } from 'prosekit/core'
import { defineCodeBlock, defineCodeBlockPreviewPlugin, defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineDoc } from 'prosekit/extensions/doc'
import { defineParagraph } from 'prosekit/extensions/paragraph'
import { defineText } from 'prosekit/extensions/text'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
export function defineExtension() {
return union(
defineBaseKeymap(),
defineDoc(),
defineText(),
defineParagraph(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineCodeBlockPreviewPlugin(),
defineCodeBlockView(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}<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 { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import type { ProseMirrorNode } from 'prosekit/pm/model'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { Decoration } from 'prosekit/pm/view'
import type { SvelteNodeViewProps } from 'prosekit/svelte'
import { fromStore } from 'svelte/store'
interface Props extends SvelteNodeViewProps {}
const props: Props = $props()
const node: ProseMirrorNode = $derived(fromStore(props.node).current)
const decorations: readonly Decoration[] = $derived(fromStore(props.decorations).current)
const attrs = $derived(node.attrs as CodeBlockAttrs)
const language = $derived(attrs.language || '')
const hidePreview = $derived(decorations.some(isCodeBlockPreviewHiddenDecoration))
const showMermaidPreview = $derived(!hidePreview && language === 'mermaid')
let preElement: HTMLPreElement | null = null
const mermaidPreview = $derived.by<{ svg: string | null; error: Error | null }>(() => {
if (language !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(node.textContent, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
})
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
function bindContentRef(element: HTMLPreElement) {
props.contentRef(element)
preElement = element
}
function focusSource(event: MouseEvent) {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
</script>
<div
class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable="false"
data-preview={showMermaidPreview ? '' : undefined}
>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
value={language}
onchange={(event) => setLanguage((event.target as HTMLSelectElement).value)}
>
<option value="">Plain Text</option>
{#each shikiBundledLanguagesInfo as info (info.id)}
<option value={info.id}>
{info.name}
</option>
{/each}
</select>
</div>
<pre
use:bindContentRef
class="data-preview:hidden"
data-preview={showMermaidPreview ? '' : undefined}
data-language={language}
></pre>
{#if showMermaidPreview}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
aria-label="Edit source"
class="block py-2 overflow-auto"
contentEditable="false"
onmousedown={focusSource}
>
{#if mermaidPreview.error}
<pre>{mermaidPreview.error.message}</pre>
{/if}
{#if mermaidPreview.svg}
<div>{@html mermaidPreview.svg}</div>
{/if}
</div>
{/if}import type { Extension } from 'prosekit/core'
import { defineSvelteNodeView, type SvelteNodeViewComponent } from 'prosekit/svelte'
import CodeBlockView from './code-block-view.svelte'
export function defineCodeBlockView(): Extension {
return defineSvelteNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as SvelteNodeViewComponent,
})
}<script lang="ts">
import 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/index.ts'
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/index.ts'
import { ImageUploadPopover } from '../image-upload-popover/index.ts'
interface Props {
uploader?: Uploader<string>
}
const props: Props = $props()
const uploader = $derived(props.uploader)
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
const items = useEditorDerivedValue(getToolbarItems)
</script>
<div class="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
{#if $items.undo}
<Button
pressed={$items.undo.isActive}
disabled={!$items.undo.canExec}
onClick={$items.undo.command}
tooltip="Undo"
>
<div class="i-lucide-undo-2 size-5 block"></div>
</Button>
{/if}
{#if $items.redo}
<Button
pressed={$items.redo.isActive}
disabled={!$items.redo.canExec}
onClick={$items.redo.command}
tooltip="Redo"
>
<div class="i-lucide-redo-2 size-5 block"></div>
</Button>
{/if}
{#if $items.bold}
<Button
pressed={$items.bold.isActive}
disabled={!$items.bold.canExec}
onClick={$items.bold.command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
{/if}
{#if $items.italic}
<Button
pressed={$items.italic.isActive}
disabled={!$items.italic.canExec}
onClick={$items.italic.command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
{/if}
{#if $items.underline}
<Button
pressed={$items.underline.isActive}
disabled={!$items.underline.canExec}
onClick={$items.underline.command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
{/if}
{#if $items.strike}
<Button
pressed={$items.strike.isActive}
disabled={!$items.strike.canExec}
onClick={$items.strike.command}
tooltip="Strike"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
{/if}
{#if $items.code}
<Button
pressed={$items.code.isActive}
disabled={!$items.code.canExec}
onClick={$items.code.command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
{/if}
{#if $items.codeBlock}
<Button
pressed={$items.codeBlock.isActive}
disabled={!$items.codeBlock.canExec}
onClick={$items.codeBlock.command}
tooltip="Code Block"
>
<div class="i-lucide-square-code size-5 block"></div>
</Button>
{/if}
{#if $items.heading1}
<Button
pressed={$items.heading1.isActive}
disabled={!$items.heading1.canExec}
onClick={$items.heading1.command}
tooltip="Heading 1"
>
<div class="i-lucide-heading-1 size-5 block"></div>
</Button>
{/if}
{#if $items.heading2}
<Button
pressed={$items.heading2.isActive}
disabled={!$items.heading2.canExec}
onClick={$items.heading2.command}
tooltip="Heading 2"
>
<div class="i-lucide-heading-2 size-5 block"></div>
</Button>
{/if}
{#if $items.heading3}
<Button
pressed={$items.heading3.isActive}
disabled={!$items.heading3.canExec}
onClick={$items.heading3.command}
tooltip="Heading 3"
>
<div class="i-lucide-heading-3 size-5 block"></div>
</Button>
{/if}
{#if $items.horizontalRule}
<Button
pressed={$items.horizontalRule.isActive}
disabled={!$items.horizontalRule.canExec}
onClick={$items.horizontalRule.command}
tooltip="Divider"
>
<div class="i-lucide-minus size-5 block"></div>
</Button>
{/if}
{#if $items.blockquote}
<Button
pressed={$items.blockquote.isActive}
disabled={!$items.blockquote.canExec}
onClick={$items.blockquote.command}
tooltip="Blockquote"
>
<div class="i-lucide-text-quote size-5 block"></div>
</Button>
{/if}
{#if $items.bulletList}
<Button
pressed={$items.bulletList.isActive}
disabled={!$items.bulletList.canExec}
onClick={$items.bulletList.command}
tooltip="Bullet List"
>
<div class="i-lucide-list size-5 block"></div>
</Button>
{/if}
{#if $items.orderedList}
<Button
pressed={$items.orderedList.isActive}
disabled={!$items.orderedList.canExec}
onClick={$items.orderedList.command}
tooltip="Ordered List"
>
<div class="i-lucide-list-ordered size-5 block"></div>
</Button>
{/if}
{#if $items.taskList}
<Button
pressed={$items.taskList.isActive}
disabled={!$items.taskList.canExec}
onClick={$items.taskList.command}
tooltip="Task List"
>
<div class="i-lucide-list-checks size-5 block"></div>
</Button>
{/if}
{#if $items.toggleList}
<Button
pressed={$items.toggleList.isActive}
disabled={!$items.toggleList.canExec}
onClick={$items.toggleList.command}
tooltip="Toggle List"
>
<div class="i-lucide-list-collapse size-5 block"></div>
</Button>
{/if}
{#if $items.indentList}
<Button
pressed={$items.indentList.isActive}
disabled={!$items.indentList.canExec}
onClick={$items.indentList.command}
tooltip="Increase indentation"
>
<div class="i-lucide-indent-increase size-5 block"></div>
</Button>
{/if}
{#if $items.dedentList}
<Button
pressed={$items.dedentList.isActive}
disabled={!$items.dedentList.canExec}
onClick={$items.dedentList.command}
tooltip="Decrease indentation"
>
<div class="i-lucide-indent-decrease size-5 block"></div>
</Button>
{/if}
{#if uploader && $items.insertImage}
<ImageUploadPopover
{uploader}
disabled={!$items.insertImage.canExec}
tooltip="Insert Image"
>
<div class="i-lucide-image size-5 block"></div>
</ImageUploadPopover>
{/if}
</div>- examples/code-block/editor.vue
- examples/code-block/extension.ts
- examples/code-block/index.ts
- sample/sample-doc-code-block.ts
- ui/button/button.vue
- ui/button/index.ts
- ui/code-block-view/code-block-view.vue
- ui/code-block-view/index.ts
- ui/image-upload-popover/image-upload-popover.vue
- ui/image-upload-popover/index.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.vue
<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { defineExtension } from './extension.ts'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({
extension,
defaultContent,
})
</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 />
<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 { defineBaseKeymap, union } from 'prosekit/core'
import { defineCodeBlock, defineCodeBlockPreviewPlugin, defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineDoc } from 'prosekit/extensions/doc'
import { defineParagraph } from 'prosekit/extensions/paragraph'
import { defineText } from 'prosekit/extensions/text'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
export function defineExtension() {
return union(
defineBaseKeymap(),
defineDoc(),
defineText(),
defineParagraph(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineCodeBlockPreviewPlugin(),
defineCodeBlockView(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}<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 { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { VueNodeViewProps } from 'prosekit/vue'
import { computed, type ComponentPublicInstance } from 'vue'
const props = defineProps<VueNodeViewProps>()
const attrs = computed(() => props.node.value.attrs as CodeBlockAttrs)
const language = computed(() => attrs.value.language || '')
const hidePreview = computed(() => props.decorations.value.some(isCodeBlockPreviewHiddenDecoration))
const showMermaidPreview = computed(() => !hidePreview.value && language.value === 'mermaid')
let preElement: HTMLPreElement | null = null
const mermaidPreview = computed<{ svg: string | null; error: Error | null }>(() => {
if (language.value !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(props.node.value.textContent, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
})
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
function focusSource(event: MouseEvent) {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
function bindContentRef(element: Element | ComponentPublicInstance | null, refs: Record<string, unknown>) {
if (typeof props.contentRef === 'function') {
props.contentRef(element, refs)
}
preElement = element instanceof HTMLPreElement ? element : null
}
</script>
<template>
<div
class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable="false"
:data-preview="showMermaidPreview ? '' : undefined"
>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
:value="language"
@change="(event) => setLanguage((event.target as HTMLSelectElement).value)"
>
<option value="">Plain Text</option>
<option
v-for="info in shikiBundledLanguagesInfo"
:key="info.id"
:value="info.id"
>
{{ info.name }}
</option>
</select>
</div>
<pre
:ref="bindContentRef"
class="data-preview:hidden"
:data-preview="showMermaidPreview ? '' : undefined"
:data-language="language"
></pre>
<div
v-if="showMermaidPreview"
aria-label="Edit source"
class="block py-2 overflow-auto"
contentEditable="false"
@mousedown="focusSource"
>
<pre v-if="mermaidPreview.error">{{ mermaidPreview.error.message }}</pre>
<div v-if="mermaidPreview.svg" v-html="mermaidPreview.svg"></div>
</div>
</template>import type { Extension } from 'prosekit/core'
import { defineVueNodeView, type VueNodeViewComponent } from 'prosekit/vue'
import CodeBlockView from './code-block-view.vue'
export function defineCodeBlockView(): Extension {
return defineVueNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as VueNodeViewComponent,
})
}<script setup lang="ts">
import 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/index.ts'
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/index.ts'
import { ImageUploadPopover } from '../image-upload-popover/index.ts'
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>