Block handle
Block handles are floating controls that appear in the gutter next to whichever block currently contains the cursor or pointer. Use them to let users insert new blocks (+ button) or reorder existing ones (drag handle).
- examples/block-handle/editor.tsx
- examples/block-handle/extension.ts
- examples/block-handle/index.ts
- sample/sample-doc-block-handle.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/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
'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-block-handle'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { defineExtension } from './extension'
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">
<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>
<BlockHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockView } from '../../ui/code-block-view'
export function defineExtension() {
return union([defineBasicExtension(), defineCodeBlockView()])
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
level: 1,
},
content: [
{
type: 'text',
text: 'Drag and Drop Demo',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try dragging any paragraph or heading by clicking on the handle that appears on the left when you hover over it.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Getting Started',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hover over any block to see the drag handle appear. Click and drag to reorder content.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This paragraph can be moved above or below other blocks.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Different Block Types',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'You can drag paragraphs, headings, lists, code blocks, and more.',
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Lists Work Too',
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This entire list can be dragged',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Individual list items stay together',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try moving this list around',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Ordered lists also support dragging',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The numbering updates automatically',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Drag this list to see it in action',
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Code Blocks',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Even code blocks can be moved:',
},
],
},
{
type: 'codeBlock',
attrs: {
language: 'javascript',
},
content: [
{
type: 'text',
text: '// This code block can be dragged\nfunction dragAndDrop() {\n return "Easy to rearrange!"\n}',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Nested Content',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This blockquote can be moved as a single unit.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested blockquotes move together with their parent.',
},
],
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Try It Yourself',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Practice by moving this paragraph to the top of the document.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Or drag this one to between the headings above.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The drag handles make it easy to reorganize your content exactly how you want it.',
},
],
},
],
}'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''use client'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { ReactNodeViewProps } from 'prosekit/react'
export default function CodeBlockView(props: ReactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
return (
<>
<div className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable={false}>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language || ''}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre ref={props.contentRef} data-language={language}></pre>
</>
)
}import type { Extension } from 'prosekit/core'
import { defineReactNodeView, type ReactNodeViewComponent } from 'prosekit/react'
import CodeBlockView from './code-block-view'
export function defineCodeBlockView(): Extension {
return defineReactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies ReactNodeViewComponent,
})
}'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'- examples/block-handle/editor.tsx
- examples/block-handle/extension.ts
- examples/block-handle/index.ts
- sample/sample-doc-block-handle.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/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
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-block-handle'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { defineExtension } from './extension'
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">
<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>
<BlockHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockView } from '../../ui/code-block-view'
export function defineExtension() {
return union([defineBasicExtension(), defineCodeBlockView()])
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
level: 1,
},
content: [
{
type: 'text',
text: 'Drag and Drop Demo',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try dragging any paragraph or heading by clicking on the handle that appears on the left when you hover over it.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Getting Started',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hover over any block to see the drag handle appear. Click and drag to reorder content.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This paragraph can be moved above or below other blocks.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Different Block Types',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'You can drag paragraphs, headings, lists, code blocks, and more.',
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Lists Work Too',
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This entire list can be dragged',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Individual list items stay together',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try moving this list around',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Ordered lists also support dragging',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The numbering updates automatically',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Drag this list to see it in action',
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Code Blocks',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Even code blocks can be moved:',
},
],
},
{
type: 'codeBlock',
attrs: {
language: 'javascript',
},
content: [
{
type: 'text',
text: '// This code block can be dragged\nfunction dragAndDrop() {\n return "Easy to rearrange!"\n}',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Nested Content',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This blockquote can be moved as a single unit.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested blockquotes move together with their parent.',
},
],
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Try It Yourself',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Practice by moving this paragraph to the top of the document.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Or drag this one to between the headings above.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The drag handles make it easy to reorganize your content exactly how you want it.',
},
],
},
],
}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'import type { JSX } from 'preact'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { PreactNodeViewProps } from 'prosekit/preact'
export default function CodeBlockView(props: PreactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language || ''
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
const handleChange = (
event: JSX.TargetedEvent<HTMLSelectElement, Event>,
) => {
setLanguage(event.currentTarget.value)
}
return (
<>
<div className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable={false}>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={handleChange}
value={language || ''}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre ref={props.contentRef} data-language={language}></pre>
</>
)
}import type { Extension } from 'prosekit/core'
import { definePreactNodeView, type PreactNodeViewComponent } from 'prosekit/preact'
import CodeBlockView from './code-block-view'
export function defineCodeBlockView(): Extension {
return definePreactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies PreactNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/preact/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator'- examples/block-handle/editor.tsx
- examples/block-handle/extension.ts
- examples/block-handle/index.ts
- sample/sample-doc-block-handle.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/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
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-block-handle'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
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">
<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>
<BlockHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockView } from '../../ui/code-block-view'
export function defineExtension() {
return union(defineBasicExtension(), defineCodeBlockView())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
level: 1,
},
content: [
{
type: 'text',
text: 'Drag and Drop Demo',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try dragging any paragraph or heading by clicking on the handle that appears on the left when you hover over it.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Getting Started',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hover over any block to see the drag handle appear. Click and drag to reorder content.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This paragraph can be moved above or below other blocks.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Different Block Types',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'You can drag paragraphs, headings, lists, code blocks, and more.',
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Lists Work Too',
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This entire list can be dragged',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Individual list items stay together',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try moving this list around',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Ordered lists also support dragging',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The numbering updates automatically',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Drag this list to see it in action',
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Code Blocks',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Even code blocks can be moved:',
},
],
},
{
type: 'codeBlock',
attrs: {
language: 'javascript',
},
content: [
{
type: 'text',
text: '// This code block can be dragged\nfunction dragAndDrop() {\n return "Easy to rearrange!"\n}',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Nested Content',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This blockquote can be moved as a single unit.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested blockquotes move together with their parent.',
},
],
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Try It Yourself',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Practice by moving this paragraph to the top of the document.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Or drag this one to between the headings above.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The drag handles make it easy to reorganize your content exactly how you want it.',
},
],
},
],
}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'import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { SolidNodeViewProps } from 'prosekit/solid'
import { For, type JSX } from 'solid-js'
export default function CodeBlockView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as CodeBlockAttrs
const language = () => attrs().language
const setLanguage = (lang: string) => {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
return (
<>
<div class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable={false}>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language() || ''}
>
<option value="">Plain Text</option>
<For each={shikiBundledLanguagesInfo}>
{(info) => (
<option value={info.id}>
{info.name}
</option>
)}
</For>
</select>
</div>
<pre ref={props.contentRef} data-language={language()}></pre>
</>
)
}import type { Extension } from 'prosekit/core'
import { defineSolidNodeView, type SolidNodeViewComponent } from 'prosekit/solid'
import CodeBlockView from './code-block-view'
export function defineCodeBlockView(): Extension {
return defineSolidNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies SolidNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/solid/drop-indicator'
import type { JSX } from 'solid-js'
export default function DropIndicator(): JSX.Element {
return <BaseDropIndicator class="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator'- examples/block-handle/editor.svelte
- examples/block-handle/extension.ts
- examples/block-handle/index.ts
- sample/sample-doc-block-handle.ts
- ui/block-handle/block-handle.svelte
- ui/block-handle/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
<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-block-handle'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { defineExtension } from './extension'
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">
<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>
<BlockHandle />
<DropIndicator />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockView } from '../../ui/code-block-view'
export function defineExtension() {
return union(defineBasicExtension(), defineCodeBlockView())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
level: 1,
},
content: [
{
type: 'text',
text: 'Drag and Drop Demo',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try dragging any paragraph or heading by clicking on the handle that appears on the left when you hover over it.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Getting Started',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hover over any block to see the drag handle appear. Click and drag to reorder content.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This paragraph can be moved above or below other blocks.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Different Block Types',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'You can drag paragraphs, headings, lists, code blocks, and more.',
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Lists Work Too',
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This entire list can be dragged',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Individual list items stay together',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try moving this list around',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Ordered lists also support dragging',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The numbering updates automatically',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Drag this list to see it in action',
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Code Blocks',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Even code blocks can be moved:',
},
],
},
{
type: 'codeBlock',
attrs: {
language: 'javascript',
},
content: [
{
type: 'text',
text: '// This code block can be dragged\nfunction dragAndDrop() {\n return "Easy to rearrange!"\n}',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Nested Content',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This blockquote can be moved as a single unit.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested blockquotes move together with their parent.',
},
],
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Try It Yourself',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Practice by moving this paragraph to the top of the document.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Or drag this one to between the headings above.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The drag handles make it easy to reorganize your content exactly how you want it.',
},
],
},
],
}<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 type { ProseMirrorNode } from 'prosekit/pm/model'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
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 attrs = $derived(node.attrs as CodeBlockAttrs)
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
function bindContentRef(element: HTMLPreElement) {
props.contentRef(element)
}
</script>
<div class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable="false">
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
value={attrs.language || ''}
onchange={(event) => setLanguage((event.target as HTMLSelectElement).value)}
>
<option value="">Plain Text</option>
{#each shikiBundledLanguagesInfo as info (info.id)}
<option value={info.id}>
{info.name}
</option>
{/each}
</select>
</div>
<pre use:bindContentRef data-language={attrs.language}></pre>import type { Extension } from 'prosekit/core'
import { defineSvelteNodeView, type SvelteNodeViewComponent } from 'prosekit/svelte'
import CodeBlockView from './code-block-view.svelte'
export function defineCodeBlockView(): Extension {
return defineSvelteNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as SvelteNodeViewComponent,
})
}<script lang="ts">
import { DropIndicator as BaseDropIndicator } from 'prosekit/svelte/drop-indicator'
</script>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />export { default as DropIndicator } from './drop-indicator.svelte'- examples/block-handle/editor.vue
- examples/block-handle/extension.ts
- examples/block-handle/index.ts
- sample/sample-doc-block-handle.ts
- ui/block-handle/block-handle.vue
- ui/block-handle/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
<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-block-handle'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { defineExtension } from './extension'
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">
<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" />
<BlockHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockView } from '../../ui/code-block-view'
export function defineExtension() {
return union([defineBasicExtension(), defineCodeBlockView()])
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
level: 1,
},
content: [
{
type: 'text',
text: 'Drag and Drop Demo',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try dragging any paragraph or heading by clicking on the handle that appears on the left when you hover over it.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Getting Started',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hover over any block to see the drag handle appear. Click and drag to reorder content.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This paragraph can be moved above or below other blocks.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Different Block Types',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'You can drag paragraphs, headings, lists, code blocks, and more.',
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Lists Work Too',
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This entire list can be dragged',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Individual list items stay together',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try moving this list around',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Ordered lists also support dragging',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The numbering updates automatically',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Drag this list to see it in action',
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Code Blocks',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Even code blocks can be moved:',
},
],
},
{
type: 'codeBlock',
attrs: {
language: 'javascript',
},
content: [
{
type: 'text',
text: '// This code block can be dragged\nfunction dragAndDrop() {\n return "Easy to rearrange!"\n}',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Nested Content',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This blockquote can be moved as a single unit.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested blockquotes move together with their parent.',
},
],
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Try It Yourself',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Practice by moving this paragraph to the top of the document.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Or drag this one to between the headings above.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The drag handles make it easy to reorganize your content exactly how you want it.',
},
],
},
],
}<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 type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { VueNodeViewProps } from 'prosekit/vue'
const props = defineProps<VueNodeViewProps>()
const attrs = () => props.node.value.attrs as CodeBlockAttrs
const language = () => attrs().language
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
</script>
<template>
<div class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable="false">
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
:value="language() || ''"
@change="(event) => setLanguage((event.target as HTMLSelectElement).value)"
>
<option value="">Plain Text</option>
<option
v-for="info in shikiBundledLanguagesInfo"
:key="info.id"
:value="info.id"
>
{{ info.name }}
</option>
</select>
</div>
<pre :ref="contentRef" :data-language="language()"></pre>
</template>import type { Extension } from 'prosekit/core'
import { defineVueNodeView, type VueNodeViewComponent } from 'prosekit/vue'
import CodeBlockView from './code-block-view.vue'
export function defineCodeBlockView(): Extension {
return defineVueNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as VueNodeViewComponent,
})
}<script setup lang="ts">
import { DropIndicator as BaseDropIndicator } from 'prosekit/vue/drop-indicator'
</script>
<template>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />
</template>export { default as DropIndicator } from './drop-indicator.vue'- examples/block-handle/editor.ts
- examples/block-handle/extension.ts
- examples/block-handle/index.ts
- sample/sample-doc-block-handle.ts
- ui/block-handle/block-handle.ts
- ui/block-handle/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
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-block-handle'
import { registerLitEditorBlockHandle } from '../../ui/block-handle'
import { registerLitEditorDropIndicator } from '../../ui/drop-indicator'
import { editorContext } from '../../ui/editor-context'
import { defineExtension } from './extension'
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">
<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-block-handle></lit-editor-block-handle>
<lit-editor-drop-indicator></lit-editor-drop-indicator>
</div>
</div>`
}
}
export function registerLitEditor() {
registerLitEditorBlockHandle()
registerLitEditorDropIndicator()
if (customElements.get('lit-editor-example-block-handle')) return
customElements.define('lit-editor-example-block-handle', LitEditor)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-example-block-handle': LitEditor
}
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockView } from '../../ui/code-block-view'
export function defineExtension() {
return union(defineBasicExtension(), defineCodeBlockView())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { LitEditor as ExampleEditor, registerLitEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
level: 1,
},
content: [
{
type: 'text',
text: 'Drag and Drop Demo',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try dragging any paragraph or heading by clicking on the handle that appears on the left when you hover over it.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Getting Started',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hover over any block to see the drag handle appear. Click and drag to reorder content.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This paragraph can be moved above or below other blocks.',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Different Block Types',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'You can drag paragraphs, headings, lists, code blocks, and more.',
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Lists Work Too',
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This entire list can be dragged',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Individual list items stay together',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'bullet',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Try moving this list around',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Ordered lists also support dragging',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The numbering updates automatically',
},
],
},
],
},
{
type: 'list',
attrs: {
kind: 'ordered',
order: null,
checked: false,
collapsed: false,
},
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Drag this list to see it in action',
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 3,
},
content: [
{
type: 'text',
text: 'Code Blocks',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Even code blocks can be moved:',
},
],
},
{
type: 'codeBlock',
attrs: {
language: 'javascript',
},
content: [
{
type: 'text',
text: '// This code block can be dragged\nfunction dragAndDrop() {\n return "Easy to rearrange!"\n}',
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Nested Content',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This blockquote can be moved as a single unit.',
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested blockquotes move together with their parent.',
},
],
},
],
},
],
},
{
type: 'heading',
attrs: {
level: 2,
},
content: [
{
type: 'text',
text: 'Try It Yourself',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Practice by moving this paragraph to the top of the document.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Or drag this one to between the headings above.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The drag handles make it easy to reorganize your content exactly how you want it.',
},
],
},
],
}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'
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'import type { Extension } from 'prosekit/core'
import { defineNodeView } from 'prosekit/core'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { ProseMirrorNode } from 'prosekit/pm/model'
import type { EditorView } from 'prosekit/pm/view'
class CodeBlockNodeView {
dom: HTMLElement
contentDOM: HTMLElement
private node: ProseMirrorNode
private view: EditorView
private getPos: () => number | undefined
private select: HTMLSelectElement
private pre: HTMLPreElement
constructor(node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined) {
this.node = node
this.view = view
this.getPos = getPos
const root = document.createElement('div')
root.setAttribute('data-node-view-root', 'true')
const wrapper = document.createElement('div')
wrapper.className = 'relative mx-2 top-3 h-0 select-none overflow-visible text-xs'
wrapper.setAttribute('contenteditable', 'false')
this.select = document.createElement('select')
this.select.setAttribute('aria-label', 'Code block language')
this.select.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'
const plain = document.createElement('option')
plain.value = ''
plain.textContent = 'Plain Text'
this.select.appendChild(plain)
for (const info of shikiBundledLanguagesInfo) {
const option = document.createElement('option')
option.value = info.id
option.textContent = info.name
this.select.appendChild(option)
}
this.select.addEventListener('change', this.handleChange)
wrapper.appendChild(this.select)
this.pre = document.createElement('pre')
this.contentDOM = document.createElement('code')
this.contentDOM.setAttribute('data-node-view-content', 'true')
this.contentDOM.style.whiteSpace = 'inherit'
this.pre.appendChild(this.contentDOM)
root.appendChild(wrapper)
root.appendChild(this.pre)
this.dom = root
this.syncAttrs()
}
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 syncAttrs() {
const language = (this.node.attrs as CodeBlockAttrs).language || ''
this.select.value = language
if (language) {
this.pre.setAttribute('data-language', language)
} else {
this.pre.removeAttribute('data-language')
}
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) return false
this.node = node
this.syncAttrs()
return true
}
destroy() {
this.select.removeEventListener('change', this.handleChange)
}
}
export function defineCodeBlockView(): Extension {
return defineNodeView({
name: 'codeBlock',
constructor: (node, view, getPos) => new CodeBlockNodeView(node, view, getPos),
})
}export { defineCodeBlockView } from './code-block-view'import { ContextConsumer } from '@lit/context'
import { html, LitElement } from 'lit'
import { registerDropIndicatorElement } from 'prosekit/lit/drop-indicator'
import { editorContext } from '../editor-context'
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'import { createContext } from '@lit/context'
import type { Editor } from 'prosekit/core'
export const editorContext = createContext<Editor | undefined>('prosekit-editor')Install
Section titled “Install”npx shadcn@latest add @prosekit/react-ui-block-handlenpx shadcn@latest add @prosekit/preact-ui-block-handlenpx shadcn@latest add @prosekit/solid-ui-block-handlenpx shadcn@latest add @prosekit/svelte-ui-block-handlenpx shadcn@latest add @prosekit/vue-ui-block-handleCopy and paste the code from the demo above into your project.
Structure
Section titled “Structure”BlockHandleRoot # root component
└── BlockHandlePositioner # anchors the popup to the gutter
└── BlockHandlePopup # the floating container
├── BlockHandleAdd # "+" button that inserts a new block below
└── BlockHandleDraggable # "⋮⋮" drag handle that reorders by draggingYou can keep both BlockHandleAdd and BlockHandleDraggable, or only one. The popup is just the visible container. Pair this with the Drop indicator component to draw a horizontal line where the dragged block will land.
API reference
Section titled “API reference”- prosekit/react/block-handle
- prosekit/vue/block-handle
- prosekit/preact/block-handle
- prosekit/svelte/block-handle
- prosekit/solid/block-handle
See also
Section titled “See also”- Drop indicator: companion overlay that shows the drop target during a drag.
- Table handle: sibling primitive for table-row and table-column selection.