Example: block-handle
A handle that appears on hover, allowing you to drag and re-order blocks.
Install this example with
shadcn: npx shadcn@latest add @prosekit/react-example-block-handlenpx shadcn@latest add @prosekit/lit-example-block-handlenpx shadcn@latest add @prosekit/preact-example-block-handlenpx shadcn@latest add @prosekit/solid-example-block-handlenpx shadcn@latest add @prosekit/svelte-example-block-handlenpx shadcn@latest add @prosekit/vue-example-block-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.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')- 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'