Example: full
Full-featured editor with most available features enabled.
Install this example with
shadcn: npx shadcn@latest add @prosekit/react-example-fullnpx shadcn@latest add @prosekit/lit-example-fullnpx shadcn@latest add @prosekit/preact-example-fullnpx shadcn@latest add @prosekit/solid-example-fullnpx shadcn@latest add @prosekit/svelte-example-fullnpx shadcn@latest add @prosekit/vue-example-full- examples/full/editor.tsx
- examples/full/extension.ts
- examples/full/html.ts
- examples/full/index.ts
- sample/katex.ts
- sample/sample-doc-full.ts
- sample/sample-tag-data.ts
- sample/sample-uploader.ts
- sample/sample-user-data.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.tsx
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.tsx
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.tsx
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.tsx
- ui/slash-menu/slash-menu-item.tsx
- ui/slash-menu/slash-menu.tsx
- ui/table-handle/index.ts
- ui/table-handle/table-handle.tsx
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.tsx
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
- ui/user-menu/index.ts
- ui/user-menu/user-menu.tsx
'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-full.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { users } from '../../sample/sample-user-data.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { InlineMenu } from '../../ui/inline-menu/index.ts'
import { SlashMenu } from '../../ui/slash-menu/index.ts'
import { TableHandle } from '../../ui/table-handle/index.ts'
import { TagMenu } from '../../ui/tag-menu/index.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { UserMenu } from '../../ui/user-menu/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 uploader={sampleUploader} />
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMath } from 'prosekit/extensions/math'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { renderKaTeXMathBlock, renderKaTeXMathInline } from '../../sample/katex.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
import { defineImageView } from '../../ui/image-view/index.ts'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineMath({ renderMathBlock: renderKaTeXMathBlock, renderMathInline: renderKaTeXMathInline }),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>import { createEditor, type NodeJSON } from 'prosekit/core'
import { sampleContent } from '../../sample/sample-doc-full.ts'
import { defineExtension } from './extension.ts'
/**
* Renders a ProseMirror document JSON object to an HTML string.
*
* This is useful for server-side rendering.
*
* @example
*
* ```js
* import { JSDOM } from 'jsdom'
* const dom = new JSDOM('')
* const document = dom.window.document
* const html = renderHTML(document, myContentJSON)
* ```
*/
export function renderHTML(document: Document, content: NodeJSON = sampleContent): string {
const extension = defineExtension()
const editor = createEditor({ extension })
editor.setContent(content)
const html: string = editor.getDocHTML({ document })
if (html.startsWith('<div>') && html.endsWith('</div>')) {
return html.slice(5, -6) // Remove the wrapping <div> tags
} else {
console.error('Unexpected HTML format: expected a single <div> wrapper')
return html
}
}export { default as ExampleEditor } from './editor.tsx'
export { renderHTML } from './html.ts'import { render } from 'katex'
export function renderKaTeXMathBlock(text: string, element: HTMLElement) {
render(text, element, { displayMode: true, throwOnError: false, output: 'mathml' })
}
export function renderKaTeXMathInline(text: string, element: HTMLElement) {
render(text, element, { displayMode: false, throwOnError: false, output: 'mathml' })
}import type { NodeJSON } from 'prosekit/core'
const EULER_IDENTITY = String.raw`e^{i\pi} + 1 = 0`
const GAUSSIAN_INTEGRAL = String.raw`\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}`
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ',
},
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Bullet points that guide thoughts' }],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] }],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] }],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Math that renders.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline math like ' },
{ type: 'mathInline', content: [{ type: 'text', text: EULER_IDENTITY }] },
{ type: 'text', text: ' appears within text. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$...$' },
{ type: 'text', text: ' to insert an inline equation.' },
],
},
{
type: 'mathBlock',
content: [{ type: 'text', text: GAUSSIAN_INTEGRAL }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$$' },
{ type: 'text', text: ' in a new line and press Enter to create a block equation.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]'use client'
import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopup, BlockHandlePositioner, BlockHandleRoot } from 'prosekit/react/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function BlockHandle(props: Props) {
return (
<BlockHandleRoot>
<BlockHandlePositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<BlockHandlePopup 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">
<BlockHandleAdd className="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div className="i-lucide-plus size-5 block" />
</BlockHandleAdd>
<BlockHandleDraggable className="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div className="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopup>
</BlockHandlePositioner>
</BlockHandleRoot>
)
}export { default as BlockHandle } from './block-handle.tsx''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 { DropIndicator as BaseDropIndicator } from 'prosekit/react/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator.tsx''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''use client'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { ReactNodeViewProps } from 'prosekit/react'
import { ResizableHandle, ResizableRoot } from 'prosekit/react/resizable'
import { useEffect, useState, type SyntheticEvent } from 'react'
export default function ImageView(props: ReactNodeViewProps) {
const attrs = props.node.attrs as ImageAttrs
const url = attrs.src || ''
const uploading = url.startsWith('blob:')
const [aspectRatio, setAspectRatio] = useState<number | undefined>()
const [error, setError] = useState<string | undefined>()
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!uploading) return
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((error) => {
if (canceled) return
setError(String(error))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
return () => {
canceled = true
unsubscribeProgress()
}
}, [url, uploading])
const handleImageLoad = (event: SyntheticEvent) => {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => props.setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
alt="upload preview"
className="h-full w-full max-w-full max-h-full object-contain"
/>
)}
{uploading && !error && (
<div className="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div className="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
)}
{error && (
<div className="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div className="i-lucide-image-off size-8 block"></div>
<div className="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div className="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import { defineReactNodeView, type ReactNodeViewComponent } from 'prosekit/react'
import ImageView from './image-view.tsx'
export function defineImageView(): Extension {
return defineReactNodeView({
name: 'image',
component: ImageView satisfies ReactNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.tsx''use client'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/react'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/react/inline-popover'
import { useState } from 'react'
import { Button } from '../button/index.ts'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
export default function InlineMenu() {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
return (
<>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) {
setLinkMenuOpen(false)
}
}}
>
<InlinePopoverPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-lg p-1"
>
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block"></div>
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block"></div>
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block"></div>
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strikethrough"
>
<div className="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block"></div>
</Button>
)}
{items.link && items.link.canExec && (
<Button
pressed={items.link.isActive}
onClick={() => {
items.link?.command?.()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className="i-lucide-link size-5 block"></div>
</Button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
{items.link && (
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={(event) => setLinkMenuOpen(event.detail)}
>
<InlinePopoverPositioner placement="bottom" className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{linkMenuOpen && (
<form
onSubmit={(event) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}}
>
<input
placeholder="Paste the link..."
defaultValue={items.link.currentLink}
className="flex h-9 rounded-md w-full bg-[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"
/>
</form>
)}
{items.link.isActive && (
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
)}
</>
)
}export { default as SlashMenu } from './slash-menu.tsx''use client'
import { AutocompleteEmpty } from 'prosekit/react/autocomplete'
export default function SlashMenuEmpty() {
return (
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}'use client'
import { AutocompleteItem } from 'prosekit/react/autocomplete'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}) {
return (
<AutocompleteItem onSelect={props.onSelect} className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>{props.label}</span>
{props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
</AutocompleteItem>
)
}'use client'
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/react'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/react/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.tsx'
import SlashMenuItem from './slash-menu-item.tsx'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => editor.commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => editor.commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => editor.commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => editor.commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => editor.commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => editor.commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => editor.commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => editor.commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => editor.commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => editor.commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => editor.commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => editor.commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}export { default as TableHandle } from './table-handle.tsx''use client'
import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/react'
import { MenuItem, MenuPopup, MenuPositioner } from 'prosekit/react/menu'
import {
TableHandleColumnMenuRoot,
TableHandleColumnMenuTrigger,
TableHandleColumnPopup,
TableHandleColumnPositioner,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandleRoot,
TableHandleRowMenuRoot,
TableHandleRowMenuTrigger,
TableHandleRowPopup,
TableHandleRowPositioner,
} from 'prosekit/react/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function TableHandle(props: Props) {
const state = useEditorDerivedValue(getTableHandleState)
return (
<TableHandleRoot>
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TableHandleColumnPopup className="translate-y-[50%] 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">
<TableHandleColumnMenuRoot>
<TableHandleColumnMenuTrigger className="h-4.5 w-6 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div className="i-lucide-grip-horizontal size-5 min-h-5 min-w-5 block"></div>
</TableHandleColumnMenuTrigger>
<MenuPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
{state.addTableColumnBefore.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</MenuItem>
)}
{state.addTableColumnAfter.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</MenuItem>
)}
{state.deleteCellSelection.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
)}
{state.deleteTableColumn.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteTableColumn.command}
>
<span>Delete Column</span>
</MenuItem>
)}
{state.deleteTable.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</MenuItem>
)}
</MenuPopup>
</MenuPositioner>
</TableHandleColumnMenuRoot>
</TableHandleColumnPopup>
</TableHandleColumnPositioner>
<TableHandleRowPositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<TableHandleRowPopup className="ltr:translate-x-[50%] rtl:translate-x-[-50%] 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">
<TableHandleRowMenuRoot>
<TableHandleRowMenuTrigger className="h-6 w-4.5 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div className="i-lucide-grip-vertical size-5 min-h-5 min-w-5 block"></div>
</TableHandleRowMenuTrigger>
<MenuPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
{state.addTableRowAbove.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableRowAbove.command}
>
<span>Insert Above</span>
</MenuItem>
)}
{state.addTableRowBelow.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableRowBelow.command}
>
<span>Insert Below</span>
</MenuItem>
)}
{state.deleteCellSelection.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
)}
{state.deleteTableRow.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteTableRow.command}
>
<span>Delete Row</span>
</MenuItem>
)}
{state.deleteTable.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</MenuItem>
)}
</MenuPopup>
</MenuPositioner>
</TableHandleRowMenuRoot>
</TableHandleRowPopup>
</TableHandleRowPositioner>
</TableHandleRoot>
)
}export { default as TagMenu } from './tag-menu.tsx''use client'
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/react/autocomplete'
const regex = /#[\da-z]*$/i
export default function TagMenu(props: { tags: { id: number; label: string }[] }) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleTagInsert = (id: number, label: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
{props.tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}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>
)
}export { default as UserMenu } from './user-menu.tsx''use client'
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/react/autocomplete'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot
regex={regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}- examples/full/editor.ts
- examples/full/extension.ts
- examples/full/index.ts
- sample/katex.ts
- sample/sample-doc-full.ts
- sample/sample-tag-data.ts
- sample/sample-uploader.ts
- sample/sample-user-data.ts
- ui/block-handle/block-handle.ts
- ui/block-handle/index.ts
- ui/button/button.ts
- ui/button/index.ts
- ui/code-block-view/code-block-view.ts
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.ts
- ui/drop-indicator/index.ts
- ui/editor-context.ts
- ui/image-upload-popover/image-upload-popover.ts
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.ts
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.ts
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.ts
- ui/slash-menu/slash-menu-item.ts
- ui/slash-menu/slash-menu.ts
- ui/table-handle/index.ts
- ui/table-handle/table-handle.ts
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.ts
- ui/toolbar/index.ts
- ui/toolbar/toolbar.ts
- ui/user-menu/index.ts
- ui/user-menu/user-menu.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, NodeJSON } from 'prosekit/core'
import { createEditor } from 'prosekit/core'
import { sampleContent } from '../../sample/sample-doc-full.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { users } from '../../sample/sample-user-data.ts'
import { registerLitEditorBlockHandle } from '../../ui/block-handle/index.ts'
import { registerLitEditorDropIndicator } from '../../ui/drop-indicator/index.ts'
import { editorContext } from '../../ui/editor-context.ts'
import { registerLitEditorInlineMenu } from '../../ui/inline-menu/index.ts'
import { registerLitEditorSlashMenu } from '../../ui/slash-menu/index.ts'
import { registerLitEditorTableHandle } from '../../ui/table-handle/index.ts'
import { registerLitEditorTagMenu } from '../../ui/tag-menu/index.ts'
import { registerLitEditorToolbar } from '../../ui/toolbar/index.ts'
import { registerLitEditorUserMenu } from '../../ui/user-menu/index.ts'
import { defineExtension } from './extension.ts'
export class LitEditor extends LitElement {
static override properties = {
initialContent: { attribute: false } satisfies PropertyDeclaration<NodeJSON | undefined>,
}
initialContent?: NodeJSON
private editor?: Editor
private ref: Ref<HTMLDivElement>
constructor() {
super()
this.ref = createRef<HTMLDivElement>()
}
override createRenderRoot() {
return this
}
override disconnectedCallback() {
this.editor?.unmount()
super.disconnectedCallback()
}
override willUpdate() {
if (this.editor) {
return
}
const extension = defineExtension()
this.editor = createEditor({
extension,
defaultContent: this.initialContent ?? sampleContent,
})
new ContextProvider(this, {
context: editorContext,
initialValue: this.editor,
})
}
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>
<lit-editor-inline-menu style="display: contents;"></lit-editor-inline-menu>
<lit-editor-slash-menu style="display: contents;"></lit-editor-slash-menu>
<lit-editor-user-menu style="display: contents;" .users=${users}></lit-editor-user-menu>
<lit-editor-tag-menu style="display: contents;" .tags=${tags}></lit-editor-tag-menu>
<lit-editor-block-handle></lit-editor-block-handle>
<lit-editor-table-handle></lit-editor-table-handle>
<lit-editor-drop-indicator></lit-editor-drop-indicator>
</div>
</div>`
}
}
export function registerLitEditor() {
registerLitEditorToolbar()
registerLitEditorInlineMenu()
registerLitEditorSlashMenu()
registerLitEditorUserMenu()
registerLitEditorTagMenu()
registerLitEditorBlockHandle()
registerLitEditorTableHandle()
registerLitEditorDropIndicator()
if (customElements.get('lit-editor-example-full')) return
customElements.define('lit-editor-example-full', LitEditor)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-example-full': LitEditor
}
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMath } from 'prosekit/extensions/math'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { renderKaTeXMathBlock, renderKaTeXMathInline } from '../../sample/katex.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
import { defineImageView } from '../../ui/image-view/index.ts'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineMath({ renderMathBlock: renderKaTeXMathBlock, renderMathInline: renderKaTeXMathInline }),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { LitEditor as ExampleEditor, registerLitEditor } from './editor.ts'import { render } from 'katex'
export function renderKaTeXMathBlock(text: string, element: HTMLElement) {
render(text, element, { displayMode: true, throwOnError: false, output: 'mathml' })
}
export function renderKaTeXMathInline(text: string, element: HTMLElement) {
render(text, element, { displayMode: false, throwOnError: false, output: 'mathml' })
}import type { NodeJSON } from 'prosekit/core'
const EULER_IDENTITY = String.raw`e^{i\pi} + 1 = 0`
const GAUSSIAN_INTEGRAL = String.raw`\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}`
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ',
},
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Bullet points that guide thoughts' }],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] }],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] }],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Math that renders.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline math like ' },
{ type: 'mathInline', content: [{ type: 'text', text: EULER_IDENTITY }] },
{ type: 'text', text: ' appears within text. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$...$' },
{ type: 'text', text: ' to insert an inline equation.' },
],
},
{
type: 'mathBlock',
content: [{ type: 'text', text: GAUSSIAN_INTEGRAL }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$$' },
{ type: 'text', text: ' in a new line and press Enter to create a block equation.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]import { ContextConsumer } from '@lit/context'
import { html, LitElement } from 'lit'
import {
registerBlockHandleAddElement,
registerBlockHandleDraggableElement,
registerBlockHandlePopupElement,
registerBlockHandlePositionerElement,
registerBlockHandleRootElement,
} from 'prosekit/lit/block-handle'
import { editorContext } from '../editor-context.ts'
class LitBlockHandle extends LitElement {
declare dir: 'ltr' | 'rtl' | 'auto'
private _editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
}
override createRenderRoot() {
return this
}
override render() {
const placement = this.dir === 'rtl' ? 'right' : 'left'
const editor = this._editorConsumer.value ?? null
return html`<prosekit-block-handle-root .editor=${editor}>
<prosekit-block-handle-positioner .placement=${placement} class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-block-handle-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">
<prosekit-block-handle-add .editor=${editor} class="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-plus size-5 block"></div>
</prosekit-block-handle-add>
<prosekit-block-handle-draggable .editor=${editor} class="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-grip-vertical size-5 block"></div>
</prosekit-block-handle-draggable>
</prosekit-block-handle-popup>
</prosekit-block-handle-positioner>
</prosekit-block-handle-root>`
}
}
export function registerLitEditorBlockHandle() {
registerBlockHandleAddElement()
registerBlockHandleDraggableElement()
registerBlockHandlePopupElement()
registerBlockHandlePositionerElement()
registerBlockHandleRootElement()
if (customElements.get('lit-editor-block-handle')) return
customElements.define('lit-editor-block-handle', LitBlockHandle)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-block-handle': LitBlockHandle
}
}export { registerLitEditorBlockHandle } from './block-handle.ts'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 { ContextConsumer } from '@lit/context'
import { html, LitElement } from 'lit'
import { registerDropIndicatorElement } from 'prosekit/lit/drop-indicator'
import { editorContext } from '../editor-context.ts'
class LitDropIndicator extends LitElement {
private _editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
}
override createRenderRoot() {
return this
}
override render() {
return html`<prosekit-drop-indicator
.editor=${this._editorConsumer.value ?? null}
class="z-50 transition-all bg-blue-500"
></prosekit-drop-indicator>`
}
}
export function registerLitEditorDropIndicator() {
registerDropIndicatorElement()
if (customElements.get('lit-editor-drop-indicator')) return
customElements.define('lit-editor-drop-indicator', LitDropIndicator)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-drop-indicator': LitDropIndicator
}
}export { registerLitEditorDropIndicator } from './drop-indicator.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'import { html, render } from 'lit'
import type { Extension } from 'prosekit/core'
import { defineNodeView } from 'prosekit/core'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import {
registerResizableHandleElement,
registerResizableRootElement,
type ResizeEndEvent,
} from 'prosekit/lit/resizable'
import type { ProseMirrorNode } from 'prosekit/pm/model'
import type { Decoration, EditorView } from 'prosekit/pm/view'
interface ResizeEndEventDetail {
width: number
height: number
}
class ImageNodeView {
dom: HTMLElement
private node: ProseMirrorNode
private view: EditorView
private getPos: () => number | undefined
private selected = false
private aspectRatio: number | undefined
private error: string | undefined
private progress = 0
private unsubscribeProgress?: VoidFunction
private canceled = false
private lastUrl = ''
constructor(node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined) {
this.node = node
this.view = view
this.getPos = getPos
this.dom = document.createElement('div')
this.dom.setAttribute('data-node-view-root', 'true')
registerResizableRootElement()
registerResizableHandleElement()
this.sync()
}
private setAttrs(attrs: Partial<ImageAttrs>) {
const pos = this.getPos()
if (typeof pos !== 'number') return
const next: ImageAttrs = { ...(this.node.attrs as ImageAttrs), ...attrs }
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, next))
}
private handleResizeEnd = (event: Event) => {
const detail = (event as ResizeEndEvent).detail as ResizeEndEventDetail
this.setAttrs({ width: detail.width, height: detail.height })
}
private handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
this.aspectRatio = ratio
}
const attrs = this.node.attrs as ImageAttrs
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
this.setAttrs({ width: naturalWidth, height: naturalHeight })
} else {
this.sync()
}
}
private subscribeUpload(url: string) {
this.unsubscribeUpload()
this.canceled = false
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
uploadTask.finished.catch((err) => {
if (this.canceled) return
this.error = String(err)
this.sync()
})
this.unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (this.canceled) return
this.progress = total ? loaded / total : 0
this.sync()
})
}
private unsubscribeUpload() {
this.canceled = true
this.unsubscribeProgress?.()
this.unsubscribeProgress = undefined
}
private sync() {
const attrs = this.node.attrs as ImageAttrs
const url = attrs.src || ''
const uploading = url.startsWith('blob:')
if (url !== this.lastUrl) {
this.lastUrl = url
this.error = undefined
this.progress = 0
if (uploading) {
this.subscribeUpload(url)
} else {
this.unsubscribeUpload()
}
}
render(
html`
<prosekit-resizable-root
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
.width=${attrs.width ?? null}
.height=${attrs.height ?? null}
.aspectRatio=${this.aspectRatio ?? null}
?data-selected=${this.selected}
@resizeEnd=${this.handleResizeEnd}
>
${url && !this.error
? html`
<img
src=${url}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
@load=${this.handleImageLoad}
/>
`
: ''}
${uploading && !this.error
? html`
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>${Math.round(this.progress * 100)}%</div>
</div>
`
: ''}
${this.error
? html`
<div class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">Failed to upload image</div>
</div>
`
: ''}
<prosekit-resizable-handle class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
><div class="i-lucide-arrow-down-right size-4 block"></div
></prosekit-resizable-handle>
</prosekit-resizable-root>
`,
this.dom,
)
}
update(node: ProseMirrorNode, _decorations: readonly Decoration[]) {
if (node.type !== this.node.type) return false
this.node = node
this.sync()
return true
}
selectNode() {
this.selected = true
this.sync()
}
deselectNode() {
this.selected = false
this.sync()
}
destroy() {
this.unsubscribeUpload()
render(null, this.dom)
}
}
export function defineImageView(): Extension {
return defineNodeView({
name: 'image',
constructor: (node, view, getPos) => new ImageNodeView(node, view, getPos),
})
}export { defineImageView } from './image-view.ts'export { registerLitEditorInlineMenu } from './inline-menu.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 { LinkAttrs } from 'prosekit/extensions/link'
import {
registerInlinePopoverPopupElement,
registerInlinePopoverPositionerElement,
registerInlinePopoverRootElement,
type OpenChangeEvent,
} from 'prosekit/lit/inline-popover'
import type { EditorState } from 'prosekit/pm/state'
import { registerLitEditorButton } from '../button/index.ts'
import { editorContext } from '../editor-context.ts'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
class LitInlineMenu extends LitElement {
static override properties = {
linkMenuOpen: { state: true, attribute: false } satisfies PropertyDeclaration<boolean>,
}
private linkMenuOpen = false
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
private removeUpdateExtension?: VoidFunction
private attachedEditor?: Editor
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() {
const editor = this.editorConsumer.value
if (editor === this.attachedEditor) return
this.detachEditorListener()
this.attachedEditor = editor
if (!editor) return
this.removeUpdateExtension = editor.use(defineUpdateHandler(() => this.requestUpdate()))
}
private detachEditorListener() {
this.removeUpdateExtension?.()
this.removeUpdateExtension = undefined
this.attachedEditor = undefined
}
private handleLinkUpdate(editor: Editor<BasicExtension>, href?: string) {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
this.linkMenuOpen = false
editor.focus()
}
override render() {
const editor = this.editorConsumer.value as Editor<BasicExtension> | undefined
if (!editor) {
return nothing
}
const items = getInlineMenuItems(editor)
return html`
<prosekit-inline-popover-root
.editor=${editor}
@openChange=${(event: OpenChangeEvent) => {
if (!event.detail) {
this.linkMenuOpen = false
}
}}
>
<prosekit-inline-popover-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-inline-popover-popup
data-testid="inline-menu-main"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-lg p-1"
>
${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="Strikethrough"
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.link && items.link.canExec
? html`
<lit-editor-button
.pressed=${items.link.isActive}
tooltip="Link"
icon="i-lucide-link size-5 block"
@click=${() => {
items.link?.command?.()
this.linkMenuOpen = !this.linkMenuOpen
}}
></lit-editor-button>
`
: nothing}
</prosekit-inline-popover-popup>
</prosekit-inline-popover-positioner>
</prosekit-inline-popover-root>
${items.link
? html`
<prosekit-inline-popover-root
.editor=${editor}
.defaultOpen=${false}
.open=${this.linkMenuOpen}
@openChange=${(event: OpenChangeEvent) => {
this.linkMenuOpen = event.detail
}}
>
<prosekit-inline-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-inline-popover-popup
data-testid="inline-menu-link"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
${this.linkMenuOpen
? html`
<form
@submit=${(event: SubmitEvent) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
this.handleLinkUpdate(editor, href)
}}
>
<input
placeholder="Paste the link..."
value=${items.link.currentLink}
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"
/>
</form>
`
: nothing}
${items.link.isActive
? html`
<button
@click=${() => this.handleLinkUpdate(editor)}
@mousedown=${(event: MouseEvent) => event.preventDefault()}
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
`
: nothing}
</prosekit-inline-popover-popup>
</prosekit-inline-popover-positioner>
</prosekit-inline-popover-root>
`
: nothing}
`
}
}
export function registerLitEditorInlineMenu() {
registerLitEditorButton()
registerInlinePopoverRootElement()
registerInlinePopoverPositionerElement()
registerInlinePopoverPopupElement()
if (customElements.get('lit-editor-inline-menu')) return
customElements.define('lit-editor-inline-menu', LitInlineMenu)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-inline-menu': LitInlineMenu
}
}export { registerLitEditorSlashMenu } from './slash-menu.ts'import { html, LitElement } from 'lit'
export class SlashMenuEmptyElement extends LitElement {
override createRenderRoot() {
return this
}
override render() {
return html`
<prosekit-autocomplete-empty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</prosekit-autocomplete-empty>
`
}
}import { html, LitElement } from 'lit'
export class SlashMenuItemElement extends LitElement {
static override properties = {
label: { type: String },
kbd: { type: String },
}
label: string
kbd: string
constructor() {
super()
this.label = ''
this.kbd = ''
}
override createRenderRoot() {
return this
}
// TODO: maybe this should changed to valueChange event??
handleSelect = () => {
this.dispatchEvent(new CustomEvent('select'))
}
override render() {
return html`<prosekit-autocomplete-item
@select=${this.handleSelect}
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
>
<span>${this.label}</span>${this.kbd
? html`<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">${this.kbd}</kbd>`
: ''}
</prosekit-autocomplete-item>`
}
}import { ContextConsumer } from '@lit/context'
import { html, LitElement } from 'lit'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import { canUseRegexLookbehind } from 'prosekit/core'
import {
registerAutocompleteEmptyElement,
registerAutocompleteItemElement,
registerAutocompletePopupElement,
registerAutocompletePositionerElement,
registerAutocompleteRootElement,
} from 'prosekit/lit/autocomplete'
import { editorContext } from '../editor-context.ts'
import { SlashMenuEmptyElement } from './slash-menu-empty.ts'
import { SlashMenuItemElement } from './slash-menu-item.ts'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
class SlashMenuElement extends LitElement {
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override createRenderRoot() {
return this
}
override render() {
const editor = this.editorConsumer.value as Editor<BasicExtension> | undefined
if (!editor) {
return html``
}
return html`<prosekit-autocomplete-root .editor=${editor} .regex=${regex}>
<prosekit-autocomplete-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-autocomplete-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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<lit-editor-slash-menu-item
class="contents"
label="Text"
@select=${() => editor.commands.setParagraph()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Heading 1"
kbd="#"
@select=${() => editor.commands.setHeading({ level: 1 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Heading 2"
kbd="##"
@select=${() => editor.commands.setHeading({ level: 2 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Heading 3"
kbd="###"
@select=${() => editor.commands.setHeading({ level: 3 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Bullet list"
kbd="-"
@select=${() => editor.commands.wrapInList({ kind: 'bullet' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Ordered list"
kbd="1."
@select=${() => editor.commands.wrapInList({ kind: 'ordered' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Task list"
kbd="[]"
@select=${() => editor.commands.wrapInList({ kind: 'task' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Toggle list"
kbd=">>"
@select=${() => editor.commands.wrapInList({ kind: 'toggle' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Quote"
kbd=">"
@select=${() => editor.commands.setBlockquote()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Table"
@select=${() => editor.commands.insertTable({ row: 3, col: 3 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Divider"
kbd="---"
@select=${() => editor.commands.insertHorizontalRule()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Code"
kbd="\`\`\`"
@select=${() => editor.commands.setCodeBlock()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-empty class="contents"></lit-editor-slash-menu-empty>
</div>
</prosekit-autocomplete-popup>
</prosekit-autocomplete-positioner>
</prosekit-autocomplete-root>`
}
}
export function registerLitEditorSlashMenu() {
registerAutocompleteEmptyElement()
registerAutocompleteItemElement()
registerAutocompletePopupElement()
registerAutocompletePositionerElement()
registerAutocompleteRootElement()
if (!customElements.get('lit-editor-slash-menu-item')) {
customElements.define('lit-editor-slash-menu-item', SlashMenuItemElement)
}
if (!customElements.get('lit-editor-slash-menu-empty')) {
customElements.define('lit-editor-slash-menu-empty', SlashMenuEmptyElement)
}
if (customElements.get('lit-editor-slash-menu')) return
customElements.define('lit-editor-slash-menu', SlashMenuElement)
}export { registerLitEditorTableHandle } from './table-handle.ts'import { ContextConsumer } from '@lit/context'
import { html, LitElement, nothing, type PropertyDeclaration, type PropertyValues } from 'lit'
import type { Editor } from 'prosekit/core'
import { defineUpdateHandler } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import {
registerMenuItemElement,
registerMenuPopupElement,
registerMenuPositionerElement,
} from 'prosekit/lit/menu'
import {
registerTableHandleColumnMenuRootElement,
registerTableHandleColumnMenuTriggerElement,
registerTableHandleColumnPopupElement,
registerTableHandleColumnPositionerElement,
registerTableHandleDragPreviewElement,
registerTableHandleDropIndicatorElement,
registerTableHandleRootElement,
registerTableHandleRowMenuRootElement,
registerTableHandleRowMenuTriggerElement,
registerTableHandleRowPopupElement,
registerTableHandleRowPositionerElement,
} from 'prosekit/lit/table-handle'
import { editorContext } from '../editor-context.ts'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
class LitTableHandle extends LitElement {
static override properties = {
dir: { type: String } satisfies PropertyDeclaration<'ltr' | 'rtl'>,
}
override dir: string = ''
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
private removeUpdateExtension?: VoidFunction
private attachedEditor?: Editor
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() {
const editor = this.editorConsumer.value
if (editor === this.attachedEditor) return
this.detachEditorListener()
this.attachedEditor = editor
if (!editor) return
this.removeUpdateExtension = editor.use(defineUpdateHandler(() => this.requestUpdate()))
}
private detachEditorListener() {
this.removeUpdateExtension?.()
this.removeUpdateExtension = undefined
this.attachedEditor = undefined
}
override render() {
const editor = this.editorConsumer.value as Editor<TableExtension> | undefined
if (!editor) {
return nothing
}
const state = getTableHandleState(editor)
const placement = this.dir === 'rtl' ? 'right' : 'left'
return html`<prosekit-table-handle-root .editor=${editor}>
<prosekit-table-handle-drag-preview .editor=${editor}></prosekit-table-handle-drag-preview>
<prosekit-table-handle-drop-indicator
.editor=${editor}
></prosekit-table-handle-drop-indicator>
<prosekit-table-handle-column-positioner
.editor=${editor}
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<prosekit-table-handle-column-popup class="translate-y-[50%] 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">
<prosekit-table-handle-column-menu-root>
<prosekit-table-handle-column-menu-trigger
.editor=${editor}
class="h-4.5 w-6 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip"
>
<div class="i-lucide-grip-horizontal size-5 min-h-5 min-w-5 block"></div>
</prosekit-table-handle-column-menu-trigger>
<prosekit-menu-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-menu-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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
${state.addTableColumnBefore.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</prosekit-menu-item>`
: nothing}
${state.addTableColumnAfter.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</prosekit-menu-item>`
: nothing}
${state.deleteCellSelection.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</prosekit-menu-item>`
: nothing}
${state.deleteTableColumn.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.deleteTableColumn.command}
>
<span>Delete Column</span>
</prosekit-menu-item>`
: nothing}
${state.deleteTable.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
@select=${state.deleteTable.command}
>
<span>Delete Table</span>
</prosekit-menu-item>`
: nothing}
</prosekit-menu-popup>
</prosekit-menu-positioner>
</prosekit-table-handle-column-menu-root>
</prosekit-table-handle-column-popup>
</prosekit-table-handle-column-positioner>
<prosekit-table-handle-row-positioner
.editor=${editor}
.placement=${placement}
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<prosekit-table-handle-row-popup class="ltr:translate-x-[50%] rtl:translate-x-[-50%] 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">
<prosekit-table-handle-row-menu-root>
<prosekit-table-handle-row-menu-trigger
.editor=${editor}
class="h-6 w-4.5 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip"
>
<div class="i-lucide-grip-vertical size-5 min-h-5 min-w-5 block"></div>
</prosekit-table-handle-row-menu-trigger>
<prosekit-menu-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-menu-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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
${state.addTableRowAbove.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.addTableRowAbove.command}
>
<span>Insert Above</span>
</prosekit-menu-item>`
: nothing}
${state.addTableRowBelow.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.addTableRowBelow.command}
>
<span>Insert Below</span>
</prosekit-menu-item>`
: nothing}
${state.deleteCellSelection.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</prosekit-menu-item>`
: nothing}
${state.deleteTableRow.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${state.deleteTableRow.command}
>
<span>Delete Row</span>
</prosekit-menu-item>`
: nothing}
${state.deleteTable.canExec
? html`<prosekit-menu-item
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
@select=${state.deleteTable.command}
>
<span>Delete Table</span>
</prosekit-menu-item>`
: nothing}
</prosekit-menu-popup>
</prosekit-menu-positioner>
</prosekit-table-handle-row-menu-root>
</prosekit-table-handle-row-popup>
</prosekit-table-handle-row-positioner>
</prosekit-table-handle-root>`
}
}
export function registerLitEditorTableHandle() {
registerMenuItemElement()
registerMenuPopupElement()
registerMenuPositionerElement()
registerTableHandleColumnMenuRootElement()
registerTableHandleColumnMenuTriggerElement()
registerTableHandleColumnPopupElement()
registerTableHandleColumnPositionerElement()
registerTableHandleDragPreviewElement()
registerTableHandleDropIndicatorElement()
registerTableHandleRootElement()
registerTableHandleRowMenuRootElement()
registerTableHandleRowMenuTriggerElement()
registerTableHandleRowPopupElement()
registerTableHandleRowPositionerElement()
if (customElements.get('lit-editor-table-handle')) return
customElements.define('lit-editor-table-handle', LitTableHandle)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-table-handle': LitTableHandle
}
}export { registerLitEditorTagMenu } from './tag-menu.ts'import { ContextConsumer } from '@lit/context'
import { html, LitElement, nothing, type PropertyDeclaration } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor, Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import {
registerAutocompleteEmptyElement,
registerAutocompleteItemElement,
registerAutocompletePopupElement,
registerAutocompletePositionerElement,
registerAutocompleteRootElement,
} from 'prosekit/lit/autocomplete'
import { editorContext } from '../editor-context.ts'
const regex = /#[\da-z]*$/i
interface Tag {
id: number
label: string
}
class TagMenuElement extends LitElement {
static override properties = {
tags: { attribute: false } satisfies PropertyDeclaration<Tag[]>,
}
tags: Tag[] = []
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override createRenderRoot() {
return this
}
private handleTagInsert(
editor: Editor<Union<[MentionExtension, BasicExtension]>>,
id: number,
label: string,
) {
editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.commands.insertText({ text: ' ' })
}
override render() {
const editor = this.editorConsumer.value as
| Editor<Union<[MentionExtension, BasicExtension]>>
| undefined
if (!editor) {
return nothing
}
return html`<prosekit-autocomplete-root .editor=${editor} .regex=${regex}>
<prosekit-autocomplete-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-autocomplete-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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<prosekit-autocomplete-empty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</prosekit-autocomplete-empty>
${repeat(
this.tags,
(tag) => tag.id,
(tag) => html`
<prosekit-autocomplete-item
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${() => this.handleTagInsert(editor, tag.id, tag.label)}
>
${'#' + tag.label}
</prosekit-autocomplete-item>
`,
)}
</div>
</prosekit-autocomplete-popup>
</prosekit-autocomplete-positioner>
</prosekit-autocomplete-root>`
}
}
export function registerLitEditorTagMenu() {
registerAutocompleteEmptyElement()
registerAutocompleteItemElement()
registerAutocompletePopupElement()
registerAutocompletePositionerElement()
registerAutocompleteRootElement()
if (customElements.get('lit-editor-tag-menu')) return
customElements.define('lit-editor-tag-menu', TagMenuElement)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-tag-menu': TagMenuElement
}
}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
private attachedEditor?: Editor
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() {
const editor = this.editorConsumer.value
if (editor === this.attachedEditor) return
this.detachEditorListener()
this.attachedEditor = editor
if (!editor) return
this.removeUpdateExtension = editor.use(defineUpdateHandler(() => this.requestUpdate()))
}
private detachEditorListener() {
this.removeUpdateExtension?.()
this.removeUpdateExtension = undefined
this.attachedEditor = 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
}
}export { registerLitEditorUserMenu } from './user-menu.ts'import { ContextConsumer } from '@lit/context'
import { html, LitElement, nothing, type PropertyDeclaration } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor, Union } from 'prosekit/core'
import { canUseRegexLookbehind } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import {
registerAutocompleteEmptyElement,
registerAutocompleteItemElement,
registerAutocompletePopupElement,
registerAutocompletePositionerElement,
registerAutocompleteRootElement,
} from 'prosekit/lit/autocomplete'
import { editorContext } from '../editor-context.ts'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
interface User {
id: number
name: string
}
class UserMenuElement extends LitElement {
static override properties = {
users: { attribute: false } satisfies PropertyDeclaration<User[]>,
loading: { type: Boolean } satisfies PropertyDeclaration<boolean>,
}
users: User[] = []
loading = false
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override createRenderRoot() {
return this
}
private handleUserInsert(
editor: Editor<Union<[MentionExtension, BasicExtension]>>,
id: number,
username: string,
) {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
private handleQueryChange = (event: Event) => {
const detail = (event as CustomEvent<string>).detail
this.dispatchEvent(new CustomEvent('queryChange', { detail }))
}
private handleOpenChange = (event: Event) => {
const detail = (event as CustomEvent<boolean>).detail
this.dispatchEvent(new CustomEvent('openChange', { detail }))
}
override render() {
const editor = this.editorConsumer.value as
| Editor<Union<[MentionExtension, BasicExtension]>>
| undefined
if (!editor) {
return nothing
}
return html`<prosekit-autocomplete-root
.editor=${editor}
.regex=${regex}
@queryChange=${this.handleQueryChange}
@openChange=${this.handleOpenChange}
>
<prosekit-autocomplete-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-autocomplete-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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<prosekit-autocomplete-empty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
${this.loading ? 'Loading...' : 'No results'}
</prosekit-autocomplete-empty>
${repeat(
this.users,
(user) => user.id,
(user) => html`
<prosekit-autocomplete-item
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select=${() => this.handleUserInsert(editor, user.id, user.name)}
>
<span class=${this.loading ? 'opacity-50' : nothing}>${user.name}</span>
</prosekit-autocomplete-item>
`,
)}
</div>
</prosekit-autocomplete-popup>
</prosekit-autocomplete-positioner>
</prosekit-autocomplete-root>`
}
}
export function registerLitEditorUserMenu() {
registerAutocompleteEmptyElement()
registerAutocompleteItemElement()
registerAutocompletePopupElement()
registerAutocompletePositionerElement()
registerAutocompleteRootElement()
if (customElements.get('lit-editor-user-menu')) return
customElements.define('lit-editor-user-menu', UserMenuElement)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-user-menu': UserMenuElement
}
}- examples/full/editor.tsx
- examples/full/extension.ts
- examples/full/index.ts
- sample/katex.ts
- sample/sample-doc-full.ts
- sample/sample-tag-data.ts
- sample/sample-uploader.ts
- sample/sample-user-data.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.tsx
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.tsx
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.tsx
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.tsx
- ui/slash-menu/slash-menu-item.tsx
- ui/slash-menu/slash-menu.tsx
- ui/table-handle/index.ts
- ui/table-handle/table-handle.tsx
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.tsx
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
- ui/user-menu/index.ts
- ui/user-menu/user-menu.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-full.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { users } from '../../sample/sample-user-data.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { InlineMenu } from '../../ui/inline-menu/index.ts'
import { SlashMenu } from '../../ui/slash-menu/index.ts'
import { TableHandle } from '../../ui/table-handle/index.ts'
import { TagMenu } from '../../ui/tag-menu/index.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { UserMenu } from '../../ui/user-menu/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 uploader={sampleUploader} />
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMath } from 'prosekit/extensions/math'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { renderKaTeXMathBlock, renderKaTeXMathInline } from '../../sample/katex.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
import { defineImageView } from '../../ui/image-view/index.ts'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineMath({ renderMathBlock: renderKaTeXMathBlock, renderMathInline: renderKaTeXMathInline }),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import { render } from 'katex'
export function renderKaTeXMathBlock(text: string, element: HTMLElement) {
render(text, element, { displayMode: true, throwOnError: false, output: 'mathml' })
}
export function renderKaTeXMathInline(text: string, element: HTMLElement) {
render(text, element, { displayMode: false, throwOnError: false, output: 'mathml' })
}import type { NodeJSON } from 'prosekit/core'
const EULER_IDENTITY = String.raw`e^{i\pi} + 1 = 0`
const GAUSSIAN_INTEGRAL = String.raw`\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}`
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ',
},
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Bullet points that guide thoughts' }],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] }],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] }],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Math that renders.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline math like ' },
{ type: 'mathInline', content: [{ type: 'text', text: EULER_IDENTITY }] },
{ type: 'text', text: ' appears within text. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$...$' },
{ type: 'text', text: ' to insert an inline equation.' },
],
},
{
type: 'mathBlock',
content: [{ type: 'text', text: GAUSSIAN_INTEGRAL }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$$' },
{ type: 'text', text: ' in a new line and press Enter to create a block equation.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopup,
BlockHandlePositioner,
BlockHandleRoot,
} from 'prosekit/preact/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function BlockHandle(props: Props) {
return (
<BlockHandleRoot>
<BlockHandlePositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<BlockHandlePopup 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">
<BlockHandleAdd className="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div className="i-lucide-plus size-5 block" />
</BlockHandleAdd>
<BlockHandleDraggable className="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div className="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopup>
</BlockHandlePositioner>
</BlockHandleRoot>
)
}export { default as BlockHandle } from './block-handle.tsx'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 { DropIndicator as BaseDropIndicator } from 'prosekit/preact/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator.tsx'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'import type { JSX } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { PreactNodeViewProps } from 'prosekit/preact'
import { ResizableHandle, ResizableRoot } from 'prosekit/preact/resizable'
export default function ImageView(props: PreactNodeViewProps) {
const attrs = props.node.attrs as ImageAttrs
const url = attrs.src || ''
const uploading = url.startsWith('blob:')
const [aspectRatio, setAspectRatio] = useState<number | undefined>()
const [error, setError] = useState<string | undefined>()
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!uploading) return
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((error) => {
if (canceled) return
setError(String(error))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
return () => {
canceled = true
unsubscribeProgress()
}
}, [url, uploading])
const handleImageLoad = (
event: JSX.TargetedEvent<HTMLImageElement, Event>,
) => {
const img = event.currentTarget
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => props.setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
alt="upload preview"
className="h-full w-full max-w-full max-h-full object-contain"
/>
)}
{uploading && !error && (
<div className="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div className="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
)}
{error && (
<div className="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div className="i-lucide-image-off size-8 block"></div>
<div className="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div className="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import { definePreactNodeView, type PreactNodeViewComponent } from 'prosekit/preact'
import ImageView from './image-view.tsx'
export function defineImageView(): Extension {
return definePreactNodeView({
name: 'image',
component: ImageView satisfies PreactNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.tsx'import type { JSX } from 'preact'
import { useState } from 'preact/hooks'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/preact'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/preact/inline-popover'
import { Button } from '../button/index.ts'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
export default function InlineMenu() {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
const handleSubmit = (
event: JSX.TargetedEvent<HTMLFormElement, SubmitEvent>,
) => {
event.preventDefault()
const href = event.currentTarget.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}
return (
<>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) {
setLinkMenuOpen(false)
}
}}
>
<InlinePopoverPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-lg p-1"
>
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block"></div>
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block"></div>
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block"></div>
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strikethrough"
>
<div className="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block"></div>
</Button>
)}
{items.link && items.link.canExec && (
<Button
pressed={items.link.isActive}
onClick={() => {
items.link?.command?.()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className="i-lucide-link size-5 block"></div>
</Button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
{items.link && (
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={(event) => setLinkMenuOpen(event.detail)}
>
<InlinePopoverPositioner placement="bottom" className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{linkMenuOpen && (
<form onSubmit={handleSubmit}>
<input
placeholder="Paste the link..."
defaultValue={items.link.currentLink}
className="flex h-9 rounded-md w-full bg-[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"
/>
</form>
)}
{items.link.isActive && (
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
)}
</>
)
}export { default as SlashMenu } from './slash-menu.tsx'import { AutocompleteEmpty } from 'prosekit/preact/autocomplete'
export default function SlashMenuEmpty() {
return (
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}import { AutocompleteItem } from 'prosekit/preact/autocomplete'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}) {
return (
<AutocompleteItem onSelect={props.onSelect} className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>{props.label}</span>
{props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/preact'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/preact/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.tsx'
import SlashMenuItem from './slash-menu-item.tsx'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => editor.commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => editor.commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => editor.commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => editor.commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => editor.commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => editor.commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => editor.commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => editor.commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => editor.commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => editor.commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => editor.commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => editor.commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}export { default as TableHandle } from './table-handle.tsx'import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/preact'
import { MenuItem, MenuPopup, MenuPositioner } from 'prosekit/preact/menu'
import {
TableHandleColumnMenuRoot,
TableHandleColumnMenuTrigger,
TableHandleColumnPopup,
TableHandleColumnPositioner,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandleRoot,
TableHandleRowMenuRoot,
TableHandleRowMenuTrigger,
TableHandleRowPopup,
TableHandleRowPositioner,
} from 'prosekit/preact/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function TableHandle(props: Props) {
const state = useEditorDerivedValue(getTableHandleState)
return (
<TableHandleRoot>
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TableHandleColumnPopup className="translate-y-[50%] 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">
<TableHandleColumnMenuRoot>
<TableHandleColumnMenuTrigger className="h-4.5 w-6 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div className="i-lucide-grip-horizontal size-5 min-h-5 min-w-5 block"></div>
</TableHandleColumnMenuTrigger>
<MenuPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
{state.addTableColumnBefore.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</MenuItem>
)}
{state.addTableColumnAfter.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</MenuItem>
)}
{state.deleteCellSelection.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
)}
{state.deleteTableColumn.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteTableColumn.command}
>
<span>Delete Column</span>
</MenuItem>
)}
{state.deleteTable.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</MenuItem>
)}
</MenuPopup>
</MenuPositioner>
</TableHandleColumnMenuRoot>
</TableHandleColumnPopup>
</TableHandleColumnPositioner>
<TableHandleRowPositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<TableHandleRowPopup className="ltr:translate-x-[50%] rtl:translate-x-[-50%] 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">
<TableHandleRowMenuRoot>
<TableHandleRowMenuTrigger className="h-6 w-4.5 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div className="i-lucide-grip-vertical size-5 min-h-5 min-w-5 block"></div>
</TableHandleRowMenuTrigger>
<MenuPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
{state.addTableRowAbove.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableRowAbove.command}
>
<span>Insert Above</span>
</MenuItem>
)}
{state.addTableRowBelow.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.addTableRowBelow.command}
>
<span>Insert Below</span>
</MenuItem>
)}
{state.deleteCellSelection.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
)}
{state.deleteTableRow.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={state.deleteTableRow.command}
>
<span>Delete Row</span>
</MenuItem>
)}
{state.deleteTable.canExec && (
<MenuItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</MenuItem>
)}
</MenuPopup>
</MenuPositioner>
</TableHandleRowMenuRoot>
</TableHandleRowPopup>
</TableHandleRowPositioner>
</TableHandleRoot>
)
}export { default as TagMenu } from './tag-menu.tsx'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/preact/autocomplete'
const regex = /#[\da-z]*$/i
export default function TagMenu(props: { tags: { id: number; label: string }[] }) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleTagInsert = (id: number, label: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
{props.tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}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>
)
}export { default as UserMenu } from './user-menu.tsx'import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/preact/autocomplete'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot
regex={regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}- examples/full/editor.tsx
- examples/full/extension.ts
- examples/full/index.ts
- sample/katex.ts
- sample/sample-doc-full.ts
- sample/sample-tag-data.ts
- sample/sample-uploader.ts
- sample/sample-user-data.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.tsx
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.tsx
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.tsx
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.tsx
- ui/slash-menu/slash-menu-item.tsx
- ui/slash-menu/slash-menu.tsx
- ui/table-handle/index.ts
- ui/table-handle/table-handle.tsx
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.tsx
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
- ui/user-menu/index.ts
- ui/user-menu/user-menu.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-full.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { users } from '../../sample/sample-user-data.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { InlineMenu } from '../../ui/inline-menu/index.ts'
import { SlashMenu } from '../../ui/slash-menu/index.ts'
import { TableHandle } from '../../ui/table-handle/index.ts'
import { TagMenu } from '../../ui/tag-menu/index.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { UserMenu } from '../../ui/user-menu/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 uploader={sampleUploader} />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMath } from 'prosekit/extensions/math'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { renderKaTeXMathBlock, renderKaTeXMathInline } from '../../sample/katex.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
import { defineImageView } from '../../ui/image-view/index.ts'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineMath({ renderMathBlock: renderKaTeXMathBlock, renderMathInline: renderKaTeXMathInline }),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import { render } from 'katex'
export function renderKaTeXMathBlock(text: string, element: HTMLElement) {
render(text, element, { displayMode: true, throwOnError: false, output: 'mathml' })
}
export function renderKaTeXMathInline(text: string, element: HTMLElement) {
render(text, element, { displayMode: false, throwOnError: false, output: 'mathml' })
}import type { NodeJSON } from 'prosekit/core'
const EULER_IDENTITY = String.raw`e^{i\pi} + 1 = 0`
const GAUSSIAN_INTEGRAL = String.raw`\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}`
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ',
},
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Bullet points that guide thoughts' }],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] }],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] }],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Math that renders.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline math like ' },
{ type: 'mathInline', content: [{ type: 'text', text: EULER_IDENTITY }] },
{ type: 'text', text: ' appears within text. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$...$' },
{ type: 'text', text: ' to insert an inline equation.' },
],
},
{
type: 'mathBlock',
content: [{ type: 'text', text: GAUSSIAN_INTEGRAL }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$$' },
{ type: 'text', text: ' in a new line and press Enter to create a block equation.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopup, BlockHandlePositioner, BlockHandleRoot } from 'prosekit/solid/block-handle'
import type { JSX } from 'solid-js'
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function BlockHandle(props: Props): JSX.Element {
return (
<BlockHandleRoot>
<BlockHandlePositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<BlockHandlePopup 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">
<BlockHandleAdd class="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-plus size-5 block" />
</BlockHandleAdd>
<BlockHandleDraggable class="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopup>
</BlockHandlePositioner>
</BlockHandleRoot>
)
}export { default as BlockHandle } from './block-handle.tsx'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 { DropIndicator as BaseDropIndicator } from 'prosekit/solid/drop-indicator'
import type { JSX } from 'solid-js'
export default function DropIndicator(): JSX.Element {
return <BaseDropIndicator class="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator.tsx'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'import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { SolidNodeViewProps } from 'prosekit/solid'
import { ResizableHandle, ResizableRoot } from 'prosekit/solid/resizable'
import { createEffect, createSignal, onCleanup, Show, type JSX } from 'solid-js'
export default function ImageView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as ImageAttrs
const url = () => attrs().src || ''
const uploading = () => url().startsWith('blob:')
const [aspectRatio, setAspectRatio] = createSignal<number | undefined>()
const [error, setError] = createSignal<string | undefined>()
const [progress, setProgress] = createSignal(0)
createEffect(() => {
if (!uploading()) return
const uploadTask = UploadTask.get<string>(url())
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
setError(String(err))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
onCleanup(() => {
canceled = true
unsubscribeProgress()
})
})
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs().width || !attrs().height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs().width ?? undefined}
height={attrs().height ?? undefined}
aspectRatio={aspectRatio()}
onResizeEnd={(event) => props.setAttrs(event.detail)}
attr:data-selected={props.selected ? '' : undefined}
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
>
<Show when={url() && !error()}>
<img
src={url()}
onLoad={handleImageLoad}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
/>
</Show>
<Show when={uploading() && !error()}>
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress() * 100)}%</div>
</div>
</Show>
<Show when={error()}>
<div class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
</Show>
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div class="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import { defineSolidNodeView, type SolidNodeViewComponent } from 'prosekit/solid'
import ImageView from './image-view.tsx'
export function defineImageView(): Extension {
return defineSolidNodeView({
name: 'image',
component: ImageView satisfies SolidNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.tsx'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/solid'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/solid/inline-popover'
import { createSignal, Show, type JSX } from 'solid-js'
import { Button } from '../button/index.ts'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
export default function InlineMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = createSignal(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor().commands.addLink({ href })
} else {
editor().commands.removeLink()
}
setLinkMenuOpen(false)
editor().focus()
}
return (
<>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) {
setLinkMenuOpen(false)
}
}}
>
<InlinePopoverPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
attr:data-testid="inline-menu-main"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-lg p-1"
>
<Show when={items().bold}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().italic}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().underline}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().strike}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Strikethrough"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().code}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().link?.canExec && items().link}>
{(item) => (
<Button
pressed={item().isActive}
onClick={() => {
item().command()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
)}
</Show>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
<Show when={items().link}>
{(item) => (
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen()}
onOpenChange={(event) => setLinkMenuOpen(event.detail)}
>
<InlinePopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
attr:data-testid="inline-menu-link"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
<Show when={linkMenuOpen()}>
<form
onSubmit={(event) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}}
>
<input
placeholder="Paste the link..."
value={item().currentLink || ''}
class="flex h-9 rounded-md w-full bg-[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"
/>
</form>
</Show>
<Show when={item().isActive}>
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
</Show>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
)}
</Show>
</>
)
}export { default as SlashMenu } from './slash-menu.tsx'import { AutocompleteEmpty } from 'prosekit/solid/autocomplete'
import type { JSX } from 'solid-js'
export default function SlashMenuEmpty(): JSX.Element {
return (
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}import { AutocompleteItem } from 'prosekit/solid/autocomplete'
import { Show, type JSX } from 'solid-js'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}): JSX.Element {
return (
<AutocompleteItem onSelect={props.onSelect} class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>{props.label}</span>
<Show when={props.kbd}>
<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>
</Show>
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/solid'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/solid/autocomplete'
import type { JSX } from 'solid-js'
import SlashMenuEmpty from './slash-menu-empty.tsx'
import SlashMenuItem from './slash-menu-item.tsx'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => editor().commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => editor().commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => editor().commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => editor().commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => editor().commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => editor().commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => editor().commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => editor().commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => editor().commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => editor().commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => editor().commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => editor().commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}export { default as TableHandle } from './table-handle.tsx'import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/solid'
import { MenuItem, MenuPopup, MenuPositioner } from 'prosekit/solid/menu'
import {
TableHandleColumnMenuRoot,
TableHandleColumnMenuTrigger,
TableHandleColumnPopup,
TableHandleColumnPositioner,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandleRoot,
TableHandleRowMenuRoot,
TableHandleRowMenuTrigger,
TableHandleRowPopup,
TableHandleRowPositioner,
} from 'prosekit/solid/table-handle'
import { Show, type JSX } from 'solid-js'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function TableHandle(props: Props): JSX.Element {
const state = useEditorDerivedValue(getTableHandleState)
return (
<TableHandleRoot>
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TableHandleColumnPopup class="translate-y-[50%] 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">
<TableHandleColumnMenuRoot>
<TableHandleColumnMenuTrigger class="h-4.5 w-6 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div class="i-lucide-grip-horizontal size-5 min-h-5 min-w-5 block"></div>
</TableHandleColumnMenuTrigger>
<MenuPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
<Show when={state().addTableColumnBefore.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().addTableColumnBefore.command()}
>
<span>Insert Left</span>
</MenuItem>
</Show>
<Show when={state().addTableColumnAfter.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().addTableColumnAfter.command()}
>
<span>Insert Right</span>
</MenuItem>
</Show>
<Show when={state().deleteCellSelection.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().deleteCellSelection.command()}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
</Show>
<Show when={state().deleteTableColumn.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().deleteTableColumn.command()}
>
<span>Delete Column</span>
</MenuItem>
</Show>
<Show when={state().deleteTable.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
attr:data-danger=""
onSelect={() => state().deleteTable.command()}
>
<span>Delete Table</span>
</MenuItem>
</Show>
</MenuPopup>
</MenuPositioner>
</TableHandleColumnMenuRoot>
</TableHandleColumnPopup>
</TableHandleColumnPositioner>
<TableHandleRowPositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<TableHandleRowPopup class="ltr:translate-x-[50%] rtl:translate-x-[-50%] 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">
<TableHandleRowMenuRoot>
<TableHandleRowMenuTrigger class="h-6 w-4.5 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div class="i-lucide-grip-vertical size-5 min-h-5 min-w-5 block"></div>
</TableHandleRowMenuTrigger>
<MenuPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
<Show when={state().addTableRowAbove.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().addTableRowAbove.command()}
>
<span>Insert Above</span>
</MenuItem>
</Show>
<Show when={state().addTableRowBelow.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().addTableRowBelow.command()}
>
<span>Insert Below</span>
</MenuItem>
</Show>
<Show when={state().deleteCellSelection.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().deleteCellSelection.command()}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
</Show>
<Show when={state().deleteTableRow.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => state().deleteTableRow.command()}
>
<span>Delete Row</span>
</MenuItem>
</Show>
<Show when={state().deleteTable.canExec}>
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
attr:data-danger=""
onSelect={() => state().deleteTable.command()}
>
<span>Delete Table</span>
</MenuItem>
</Show>
</MenuPopup>
</MenuPositioner>
</TableHandleRowMenuRoot>
</TableHandleRowPopup>
</TableHandleRowPositioner>
</TableHandleRoot>
)
}export { default as TagMenu } from './tag-menu.tsx'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/solid/autocomplete'
import { For, type JSX } from 'solid-js'
const regex = /#[\da-z]*$/i
export default function TagMenu(props: { tags: { id: number; label: string }[] }): JSX.Element {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleTagInsert = (id: number, label: string) => {
editor().commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor().commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
<For each={props.tags}>
{(tag) => (
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
)}
</For>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}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>
)
}export { default as UserMenu } from './user-menu.tsx'import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/solid/autocomplete'
import { For, type JSX } from 'solid-js'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}): JSX.Element {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor().commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor().commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot
regex={regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
<For each={props.users}>
{(user) => (
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span class={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
)}
</For>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}- examples/full/editor.svelte
- examples/full/extension.ts
- examples/full/index.ts
- sample/katex.ts
- sample/sample-doc-full.ts
- sample/sample-tag-data.ts
- sample/sample-uploader.ts
- sample/sample-user-data.ts
- ui/block-handle/block-handle.svelte
- ui/block-handle/index.ts
- ui/button/button.svelte
- ui/button/index.ts
- ui/code-block-view/code-block-view.svelte
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.svelte
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.svelte
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.svelte
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.svelte
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.svelte
- ui/slash-menu/slash-menu-item.svelte
- ui/slash-menu/slash-menu.svelte
- ui/table-handle/index.ts
- ui/table-handle/table-handle.svelte
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.svelte
- ui/toolbar/index.ts
- ui/toolbar/toolbar.svelte
- ui/user-menu/index.ts
- ui/user-menu/user-menu.svelte
<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { untrack } from 'svelte'
import { sampleContent } from '../../sample/sample-doc-full.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { users } from '../../sample/sample-user-data.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { InlineMenu } from '../../ui/inline-menu/index.ts'
import { SlashMenu } from '../../ui/slash-menu/index.ts'
import { TableHandle } from '../../ui/table-handle/index.ts'
import { TagMenu } from '../../ui/tag-menu/index.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { UserMenu } from '../../ui/user-menu/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 uploader={sampleUploader} />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div {@attach editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMath } from 'prosekit/extensions/math'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { renderKaTeXMathBlock, renderKaTeXMathInline } from '../../sample/katex.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
import { defineImageView } from '../../ui/image-view/index.ts'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineMath({ renderMathBlock: renderKaTeXMathBlock, renderMathInline: renderKaTeXMathInline }),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import { render } from 'katex'
export function renderKaTeXMathBlock(text: string, element: HTMLElement) {
render(text, element, { displayMode: true, throwOnError: false, output: 'mathml' })
}
export function renderKaTeXMathInline(text: string, element: HTMLElement) {
render(text, element, { displayMode: false, throwOnError: false, output: 'mathml' })
}import type { NodeJSON } from 'prosekit/core'
const EULER_IDENTITY = String.raw`e^{i\pi} + 1 = 0`
const GAUSSIAN_INTEGRAL = String.raw`\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}`
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ',
},
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Bullet points that guide thoughts' }],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] }],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] }],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Math that renders.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline math like ' },
{ type: 'mathInline', content: [{ type: 'text', text: EULER_IDENTITY }] },
{ type: 'text', text: ' appears within text. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$...$' },
{ type: 'text', text: ' to insert an inline equation.' },
],
},
{
type: 'mathBlock',
content: [{ type: 'text', text: GAUSSIAN_INTEGRAL }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$$' },
{ type: 'text', text: ' in a new line and press Enter to create a block equation.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]<script lang="ts">
import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopup,
BlockHandlePositioner,
BlockHandleRoot,
} from 'prosekit/svelte/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
const props: Props = $props()
</script>
<BlockHandleRoot>
<BlockHandlePositioner placement={props.dir === 'rtl' ? 'right' : 'left'} class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<BlockHandlePopup 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">
<BlockHandleAdd class="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-plus size-5 block"></div>
</BlockHandleAdd>
<BlockHandleDraggable class="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-grip-vertical size-5 block"></div>
</BlockHandleDraggable>
</BlockHandlePopup>
</BlockHandlePositioner>
</BlockHandleRoot>export { default as BlockHandle } from './block-handle.svelte'<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 { DropIndicator as BaseDropIndicator } from 'prosekit/svelte/drop-indicator'
</script>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />export { default as DropIndicator } from './drop-indicator.svelte'<script lang="ts">
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/svelte'
import { 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'<script lang="ts">
import type { ProseMirrorNode } from 'prosekit/pm/model'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { SvelteNodeViewProps } from 'prosekit/svelte'
import { ResizableHandle, ResizableRoot } from 'prosekit/svelte/resizable'
import { fromStore } from 'svelte/store'
interface Props extends SvelteNodeViewProps {}
const props: Props = $props()
const node: ProseMirrorNode = $derived(fromStore(props.node).current)
const selected: boolean = $derived(fromStore(props.selected).current)
const attrs = $derived(node.attrs as ImageAttrs)
const url = $derived(attrs.src || '')
const uploading = $derived(url.startsWith('blob:'))
let aspectRatio = $state<number | undefined>(undefined)
let error = $state<string | undefined>(undefined)
let progress = $state(0)
$effect(() => {
if (!uploading) {
return
}
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
error = String(err)
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
progress = total ? loaded / total : 0
})
return () => {
canceled = true
unsubscribeProgress()
}
})
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
aspectRatio = ratio
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
</script>
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
{aspectRatio}
data-selected={selected ? '' : undefined}
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
onResizeEnd={(event) => props.setAttrs(event.detail)}
>
{#if url && !error}
<img
src={url}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
onload={handleImageLoad}
/>
{/if}
{#if uploading && !error}
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
{/if}
{#if error}
<div class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
{/if}
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div class="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>import type { Extension } from 'prosekit/core'
import { defineSvelteNodeView, type SvelteNodeViewComponent } from 'prosekit/svelte'
import ImageView from './image-view.svelte'
export function defineImageView(): Extension {
return defineSvelteNodeView({
name: 'image',
component: ImageView as SvelteNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/svelte'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/svelte/inline-popover'
import { Button } from '../button/index.ts'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const from = state.selection.$from
const marks = from.marksAcross(from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
let linkMenuOpen = $state(false)
function toggleLinkMenuOpen() {
linkMenuOpen = !linkMenuOpen
}
function handleLinkUpdate(href?: string) {
if (href) {
$editor.commands.addLink({ href })
} else {
$editor.commands.removeLink()
}
linkMenuOpen = false
$editor.focus()
}
</script>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) linkMenuOpen = false
}}
>
<InlinePopoverPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-lg p-1"
>
{#if $items.bold}
<Button
pressed={$items.bold.isActive}
disabled={!$items.bold.canExec}
onClick={$items.bold.command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
{/if}
{#if $items.italic}
<Button
pressed={$items.italic.isActive}
disabled={!$items.italic.canExec}
onClick={$items.italic.command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
{/if}
{#if $items.underline}
<Button
pressed={$items.underline.isActive}
disabled={!$items.underline.canExec}
onClick={$items.underline.command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
{/if}
{#if $items.strike}
<Button
pressed={$items.strike.isActive}
disabled={!$items.strike.canExec}
onClick={$items.strike.command}
tooltip="Strikethrough"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
{/if}
{#if $items.code}
<Button
pressed={$items.code.isActive}
disabled={!$items.code.canExec}
onClick={$items.code.command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
{/if}
{#if $items.link?.canExec && $items.link}
<Button
pressed={$items.link.isActive}
onClick={() => {
$items.link!.command()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
{/if}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={(event) => {
linkMenuOpen = event.detail
}}
>
<InlinePopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{#if linkMenuOpen && $items.link}
<form
onsubmit={(event) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}}
>
<input
placeholder="Paste the link..."
value={$items.link.currentLink || ''}
class="flex h-9 rounded-md w-full bg-[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"
/>
</form>
{/if}
{#if $items.link?.isActive}
<button
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
onclick={() => handleLinkUpdate()}
onmousedown={(e) => e.preventDefault()}
>
Remove link
</button>
{/if}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>export { default as SlashMenu } from './slash-menu.svelte'<script lang="ts">
import { AutocompleteEmpty } from 'prosekit/svelte/autocomplete'
</script>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty><script lang="ts">
import { AutocompleteItem } from 'prosekit/svelte/autocomplete'
interface Props {
label: string
kbd?: string
onSelect: () => void
}
const props: Props = $props()
</script>
<AutocompleteItem class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800" onSelect={props.onSelect}>
<span>{props.label}</span>{#if props.kbd}<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>{/if}
</AutocompleteItem><script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/svelte'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/svelte/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.svelte'
import SlashMenuItem from './slash-menu-item.svelte'
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
</script>
<AutocompleteRoot {regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => $editor.commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => $editor.commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => $editor.commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => $editor.commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => $editor.commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => $editor.commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => $editor.commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => $editor.commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => $editor.commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => $editor.commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => $editor.commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => $editor.commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>export { default as TableHandle } from './table-handle.svelte'<script lang="ts">
import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/svelte'
import { MenuItem, MenuPopup, MenuPositioner } from 'prosekit/svelte/menu'
import {
TableHandleColumnMenuRoot,
TableHandleColumnMenuTrigger,
TableHandleColumnPopup,
TableHandleColumnPositioner,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandleRoot,
TableHandleRowMenuRoot,
TableHandleRowMenuTrigger,
TableHandleRowPopup,
TableHandleRowPositioner,
} from 'prosekit/svelte/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
const props: Props = $props()
const state = useEditorDerivedValue(getTableHandleState)
</script>
<TableHandleRoot>
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TableHandleColumnPopup class="translate-y-[50%] 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">
<TableHandleColumnMenuRoot>
<TableHandleColumnMenuTrigger class="h-4.5 w-6 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div class="i-lucide-grip-horizontal size-5 min-h-5 min-w-5 block"></div>
</TableHandleColumnMenuTrigger>
<MenuPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
{#if $state.addTableColumnBefore.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</MenuItem>
{/if}
{#if $state.addTableColumnAfter.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</MenuItem>
{/if}
{#if $state.deleteCellSelection.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
{/if}
{#if $state.deleteTableColumn.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.deleteTableColumn.command}
>
<span>Delete Column</span>
</MenuItem>
{/if}
{#if $state.deleteTable.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
onSelect={$state.deleteTable.command}
>
<span>Delete Table</span>
</MenuItem>
{/if}
</MenuPopup>
</MenuPositioner>
</TableHandleColumnMenuRoot>
</TableHandleColumnPopup>
</TableHandleColumnPositioner>
<TableHandleRowPositioner
placement={props.dir === 'rtl' ? 'right' : 'left'}
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<TableHandleRowPopup class="ltr:translate-x-[50%] rtl:translate-x-[-50%] 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">
<TableHandleRowMenuRoot>
<TableHandleRowMenuTrigger class="h-6 w-4.5 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div class="i-lucide-grip-vertical size-5 min-h-5 min-w-5 block"></div>
</TableHandleRowMenuTrigger>
<MenuPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
{#if $state.addTableRowAbove.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.addTableRowAbove.command}
>
<span>Insert Above</span>
</MenuItem>
{/if}
{#if $state.addTableRowBelow.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.addTableRowBelow.command}
>
<span>Insert Below</span>
</MenuItem>
{/if}
{#if $state.deleteCellSelection.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
{/if}
{#if $state.deleteTableRow.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={$state.deleteTableRow.command}
>
<span>Delete Row</span>
</MenuItem>
{/if}
{#if $state.deleteTable.canExec}
<MenuItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger=""
onSelect={$state.deleteTable.command}
>
<span>Delete Table</span>
</MenuItem>
{/if}
</MenuPopup>
</MenuPositioner>
</TableHandleRowMenuRoot>
</TableHandleRowPopup>
</TableHandleRowPositioner>
</TableHandleRoot>export { default as TagMenu } from './tag-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/svelte/autocomplete'
interface Props {
tags: { id: number; label: string }[]
}
const props: Props = $props()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleTagInsert(id: number, label: string) {
$editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
$editor.commands.insertText({ text: ' ' })
}
const regex = /#[\da-z]*$/i
</script>
<AutocompleteRoot {regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
{#each props.tags as tag (tag.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
{/each}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>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>export { default as UserMenu } from './user-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/svelte/autocomplete'
interface Props {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}
const props: Props = $props()
const loading = $derived(props.loading ?? false)
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
$editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
$editor.commands.insertText({ text: ' ' })
}
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
</script>
<AutocompleteRoot
{regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{#each props.users as user (user.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
{#if loading}
<span class="opacity-50">
{user.name}
</span>
{:else}
<span>
{user.name}
</span>
{/if}
</AutocompleteItem>
{/each}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>- examples/full/editor.vue
- examples/full/extension.ts
- examples/full/index.ts
- sample/katex.ts
- sample/sample-doc-full.ts
- sample/sample-tag-data.ts
- sample/sample-uploader.ts
- sample/sample-user-data.ts
- ui/block-handle/block-handle.vue
- ui/block-handle/index.ts
- ui/button/button.vue
- ui/button/index.ts
- ui/code-block-view/code-block-view.vue
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.vue
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.vue
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.vue
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.vue
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.vue
- ui/slash-menu/slash-menu-item.vue
- ui/slash-menu/slash-menu.vue
- ui/table-handle/index.ts
- ui/table-handle/table-handle.vue
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.vue
- ui/toolbar/index.ts
- ui/toolbar/toolbar.vue
- ui/user-menu/index.ts
- ui/user-menu/user-menu.vue
<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-full.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { users } from '../../sample/sample-user-data.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { InlineMenu } from '../../ui/inline-menu/index.ts'
import { SlashMenu } from '../../ui/slash-menu/index.ts'
import { TableHandle } from '../../ui/table-handle/index.ts'
import { TagMenu } from '../../ui/tag-menu/index.ts'
import { Toolbar } from '../../ui/toolbar/index.ts'
import { UserMenu } from '../../ui/user-menu/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 :uploader="sampleUploader" />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu :users="users" />
<TagMenu :tags="tags" />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMath } from 'prosekit/extensions/math'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { renderKaTeXMathBlock, renderKaTeXMathInline } from '../../sample/katex.ts'
import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'
import { defineImageView } from '../../ui/image-view/index.ts'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineMath({ renderMathBlock: renderKaTeXMathBlock, renderMathInline: renderKaTeXMathInline }),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import { render } from 'katex'
export function renderKaTeXMathBlock(text: string, element: HTMLElement) {
render(text, element, { displayMode: true, throwOnError: false, output: 'mathml' })
}
export function renderKaTeXMathInline(text: string, element: HTMLElement) {
render(text, element, { displayMode: false, throwOnError: false, output: 'mathml' })
}import type { NodeJSON } from 'prosekit/core'
const EULER_IDENTITY = String.raw`e^{i\pi} + 1 = 0`
const GAUSSIAN_INTEGRAL = String.raw`\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}`
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ',
},
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Bullet points that guide thoughts' }],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] }],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] }],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] }],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] }],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Math that renders.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Inline math like ' },
{ type: 'mathInline', content: [{ type: 'text', text: EULER_IDENTITY }] },
{ type: 'text', text: ' appears within text. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$...$' },
{ type: 'text', text: ' to insert an inline equation.' },
],
},
{
type: 'mathBlock',
content: [{ type: 'text', text: GAUSSIAN_INTEGRAL }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '$$' },
{ type: 'text', text: ' in a new line and press Enter to create a block equation.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]import type { Uploader } from 'prosekit/extensions/file'
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted by the server after 1 hour.
*/
export const sampleUploader: Uploader<string> = ({ file, onProgress }): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.replace('tmpfiles.org/', 'tmpfiles.org/dl/')
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]<script setup lang="ts">
import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopup, BlockHandlePositioner, BlockHandleRoot } from 'prosekit/vue/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
const props = defineProps<Props>()
</script>
<template>
<BlockHandleRoot>
<BlockHandlePositioner :placement="props.dir === 'rtl' ? 'right' : 'left'" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<BlockHandlePopup 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">
<BlockHandleAdd class="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-plus size-5 block" />
</BlockHandleAdd>
<BlockHandleDraggable class="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopup>
</BlockHandlePositioner>
</BlockHandleRoot>
</template>export { default as BlockHandle } from './block-handle.vue'<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 { DropIndicator as BaseDropIndicator } from 'prosekit/vue/drop-indicator'
</script>
<template>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />
</template>export { default as DropIndicator } from './drop-indicator.vue'<script setup lang="ts">
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/vue'
import { 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'<script setup lang="ts">
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { VueNodeViewProps } from 'prosekit/vue'
import { ResizableHandle, ResizableRoot } from 'prosekit/vue/resizable'
import { computed, ref, watchEffect } from 'vue'
const props = defineProps<VueNodeViewProps>()
const attrs = computed(() => props.node.value.attrs as ImageAttrs)
const url = computed(() => attrs.value.src || '')
const uploading = computed(() => url.value.startsWith('blob:'))
const aspectRatio = ref<number | undefined>()
const error = ref<string | undefined>()
const progress = ref(0)
watchEffect((onCleanup) => {
if (!uploading.value) return
const uploadTask = UploadTask.get<string>(url.value)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
error.value = String(err)
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
progress.value = total ? loaded / total : 0
})
onCleanup(() => {
canceled = true
unsubscribeProgress()
})
})
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
aspectRatio.value = ratio
}
if (naturalWidth && naturalHeight && (!attrs.value.width || !attrs.value.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
</script>
<template>
<ResizableRoot
:width="attrs.width ?? undefined"
:height="attrs.height ?? undefined"
:aspect-ratio="aspectRatio"
:data-selected="props.selected.value ? '' : undefined"
class="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
@resize-end="(event) => setAttrs(event.detail)"
>
<img
v-if="url && !error"
:src="url"
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
@load="handleImageLoad"
/>
<div v-if="uploading && !error" class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{{ Math.round(progress * 100) }}%</div>
</div>
<div v-if="error" class="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
<div class="i-lucide-image-off size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
position="bottom-right"
>
<div class="i-lucide-arrow-down-right size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
</template>import type { Extension } from 'prosekit/core'
import { defineVueNodeView, type VueNodeViewComponent } from 'prosekit/vue'
import ImageView from './image-view.vue'
export function defineImageView(): Extension {
return defineVueNodeView({
name: 'image',
component: ImageView as VueNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/vue'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/vue/inline-popover'
import { ref } from 'vue'
import { Button } from '../button/index.ts'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const linkMenuOpen = ref(false)
function toggleLinkMenuOpen() {
linkMenuOpen.value = !linkMenuOpen.value
}
function handleLinkUpdate(href?: string) {
if (href) {
editor.value.commands.addLink({ href })
} else {
editor.value.commands.removeLink()
}
linkMenuOpen.value = false
editor.value.focus()
}
</script>
<template>
<InlinePopoverRoot
@open-change="(event) => {
if (!event.detail) linkMenuOpen = false
}"
>
<InlinePopoverPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-lg p-1"
>
<Button
v-if="items.bold"
:pressed="items.bold.isActive"
:disabled="!items.bold.canExec"
tooltip="Bold"
@click="items.bold.command"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
<Button
v-if="items.italic"
:pressed="items.italic.isActive"
:disabled="!items.italic.canExec"
tooltip="Italic"
@click="items.italic.command"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
<Button
v-if="items.underline"
:pressed="items.underline.isActive"
:disabled="!items.underline.canExec"
tooltip="Underline"
@click="items.underline.command"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
<Button
v-if="items.strike"
:pressed="items.strike.isActive"
:disabled="!items.strike.canExec"
tooltip="Strikethrough"
@click="items.strike.command"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
<Button
v-if="items.code"
:pressed="items.code.isActive"
:disabled="!items.code.canExec"
tooltip="Code"
@click="items.code.command"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
<Button
v-if="items.link?.canExec && items.link"
:pressed="items.link.isActive"
tooltip="Link"
@click="() => {
items.link!.command()
toggleLinkMenuOpen()
}"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
<InlinePopoverRoot
v-if="items.link"
:default-open="false"
:open="linkMenuOpen"
@open-change="(event) => {
linkMenuOpen = event.detail
}"
>
<InlinePopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
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 border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
<form
v-if="linkMenuOpen"
@submit.prevent="(event) => {
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}"
>
<input
placeholder="Paste the link..."
:value="items.link.currentLink || ''"
class="flex h-9 rounded-md w-full bg-[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"
>
</form>
<button
v-if="items.link.isActive"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
@click="() => handleLinkUpdate()"
@mousedown.prevent
>
Remove link
</button>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
</template>export { default as SlashMenu } from './slash-menu.vue'<script setup lang="ts">
import { AutocompleteEmpty } from 'prosekit/vue/autocomplete'
</script>
<template>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
</template><script setup lang="ts">
import { AutocompleteItem } from 'prosekit/vue/autocomplete'
const props = defineProps<{
label: string
kbd?: string
onSelect: () => void
}>()
</script>
<template>
<AutocompleteItem class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800" @select="props.onSelect">
<span>{{ props.label }}</span>
<kbd v-if="props.kbd" class="text-xs font-mono text-gray-400 dark:text-gray-500">{{ props.kbd }}</kbd>
</AutocompleteItem>
</template><script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/vue'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/vue/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.vue'
import SlashMenuItem from './slash-menu-item.vue'
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
</script>
<template>
<AutocompleteRoot :regex="regex">
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
@select="() => editor.commands.setParagraph()"
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
@select="() => editor.commands.setHeading({ level: 1 })"
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
@select="() => editor.commands.setHeading({ level: 2 })"
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
@select="() => editor.commands.setHeading({ level: 3 })"
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
@select="() => editor.commands.wrapInList({ kind: 'bullet' })"
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
@select="() => editor.commands.wrapInList({ kind: 'ordered' })"
/>
<SlashMenuItem
label="Task list"
kbd="[]"
@select="() => editor.commands.wrapInList({ kind: 'task' })"
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
@select="() => editor.commands.wrapInList({ kind: 'toggle' })"
/>
<SlashMenuItem
label="Quote"
kbd=">"
@select="() => editor.commands.setBlockquote()"
/>
<SlashMenuItem
label="Table"
@select="() => editor.commands.insertTable({ row: 3, col: 3 })"
/>
<SlashMenuItem
label="Divider"
kbd="---"
@select="() => editor.commands.insertHorizontalRule()"
/>
<SlashMenuItem
label="Code"
kbd="```"
@select="() => editor.commands.setCodeBlock()"
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
</template>export { default as TableHandle } from './table-handle.vue'<script setup lang="ts">
import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/vue'
import { MenuItem, MenuPopup, MenuPositioner } from 'prosekit/vue/menu'
import {
TableHandleColumnMenuRoot,
TableHandleColumnMenuTrigger,
TableHandleColumnPopup,
TableHandleColumnPositioner,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandleRoot,
TableHandleRowMenuRoot,
TableHandleRowMenuTrigger,
TableHandleRowPopup,
TableHandleRowPositioner,
} from 'prosekit/vue/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
const props = defineProps<Props>()
const state = useEditorDerivedValue(getTableHandleState)
</script>
<template>
<TableHandleRoot>
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TableHandleColumnPopup class="translate-y-[50%] 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">
<TableHandleColumnMenuRoot>
<TableHandleColumnMenuTrigger class="h-4.5 w-6 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div class="i-lucide-grip-horizontal size-5 min-h-5 min-w-5 block"></div>
</TableHandleColumnMenuTrigger>
<MenuPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
<MenuItem
v-if="state.addTableColumnBefore.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.addTableColumnBefore.command"
>
<span>Insert Left</span>
</MenuItem>
<MenuItem
v-if="state.addTableColumnAfter.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.addTableColumnAfter.command"
>
<span>Insert Right</span>
</MenuItem>
<MenuItem
v-if="state.deleteCellSelection.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.deleteCellSelection.command"
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
<MenuItem
v-if="state.deleteTableColumn.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.deleteTableColumn.command"
>
<span>Delete Column</span>
</MenuItem>
<MenuItem
v-if="state.deleteTable.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger
@select="state.deleteTable.command"
>
<span>Delete Table</span>
</MenuItem>
</MenuPopup>
</MenuPositioner>
</TableHandleColumnMenuRoot>
</TableHandleColumnPopup>
</TableHandleColumnPositioner>
<TableHandleRowPositioner
:placement="props.dir === 'rtl' ? 'right' : 'left'"
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
>
<TableHandleRowPopup class="ltr:translate-x-[50%] rtl:translate-x-[-50%] 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">
<TableHandleRowMenuRoot>
<TableHandleRowMenuTrigger class="h-6 w-4.5 flex items-center box-border justify-center bg-[canvas] hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50 border border-gray-200 dark:border-gray-800 border-solid p-0 transition-colors overflow-clip">
<div class="i-lucide-grip-vertical size-5 min-h-5 min-w-5 block"></div>
</TableHandleRowMenuTrigger>
<MenuPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<MenuPopup 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] relative flex flex-col max-h-100 min-w-32 select-none overflow-auto whitespace-nowrap p-1 outline-none">
<MenuItem
v-if="state.addTableRowAbove.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.addTableRowAbove.command"
>
<span>Insert Above</span>
</MenuItem>
<MenuItem
v-if="state.addTableRowBelow.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.addTableRowBelow.command"
>
<span>Insert Below</span>
</MenuItem>
<MenuItem
v-if="state.deleteCellSelection.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.deleteCellSelection.command"
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</MenuItem>
<MenuItem
v-if="state.deleteTableRow.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="state.deleteTableRow.command"
>
<span>Delete Row</span>
</MenuItem>
<MenuItem
v-if="state.deleteTable.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
data-danger
@select="state.deleteTable.command"
>
<span>Delete Table</span>
</MenuItem>
</MenuPopup>
</MenuPositioner>
</TableHandleRowMenuRoot>
</TableHandleRowPopup>
</TableHandleRowPositioner>
</TableHandleRoot>
</template>export { default as TagMenu } from './tag-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import { AutocompleteEmpty, AutocompleteItem, AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/vue/autocomplete'
const props = defineProps<{ tags: { id: number; label: string }[] }>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleTagInsert(id: number, label: string) {
editor.value.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.value.commands.insertText({ text: ' ' })
}
const regex = /#[\da-z]*$/i
</script>
<template>
<AutocompleteRoot :regex="regex">
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
<AutocompleteItem
v-for="tag in props.tags"
:key="tag.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="() => handleTagInsert(tag.id, tag.label)"
>
#{{ tag.label }}
</AutocompleteItem>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
</template>export { default as Toolbar } from './toolbar.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { useEditorDerivedValue } from 'prosekit/vue'
import { Button } from '../button/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>export { default as UserMenu } from './user-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import { AutocompleteEmpty, AutocompleteItem, AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/vue/autocomplete'
const props = defineProps<{
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
editor.value.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.value.commands.insertText({ text: ' ' })
}
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
</script>
<template>
<AutocompleteRoot
:regex="regex"
@query-change="(event) => props.onQueryChange?.(event.detail)"
@open-change="(event) => props.onOpenChange?.(event.detail)"
>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup 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 relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{{ props.loading ? 'Loading...' : 'No results' }}
</AutocompleteEmpty>
<AutocompleteItem
v-for="user in props.users"
:key="user.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="() => handleUserInsert(user.id, user.name)"
>
<span v-if="props.loading" class="opacity-50">
{{ user.name }}
</span>
<span v-else>
{{ user.name }}
</span>
</AutocompleteItem>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
</template>