Drop indicator
The drop indicator is a thin line drawn between blocks while a drag is in progress, signaling where the dragged content will land when the user releases. Use it whenever the editor accepts drops, whether that's block reordering via the Block handle or external drops like file uploads.
- 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.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<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/index.ts'
export function defineExtension() {
return union([defineBasicExtension(), defineCodeBlockView()])
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'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.tsx''use client'
import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { ReactNodeViewProps } from 'prosekit/react'
import { useMemo, useRef } from 'react'
export default function CodeBlockView(props: ReactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language || ''
const hidePreview = props.decorations.some(isCodeBlockPreviewHiddenDecoration)
const preRef = useRef<HTMLElement | null>(null)
const showMermaidPreview = !hidePreview && language === 'mermaid'
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
const focusSource = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
const code = props.node.textContent
const mermaidPreview = useMemo(() => {
if (language !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(code, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
}, [code, language])
return (
<>
<div
className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable={false}
data-preview={showMermaidPreview ? '' : undefined}
>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre
ref={(element) => {
props.contentRef(element)
preRef.current = element
}}
className="data-preview:hidden"
data-preview={showMermaidPreview ? '' : undefined}
data-language={language}
></pre>
{showMermaidPreview && (
<div
aria-label="Edit source"
className="block py-2 overflow-auto"
contentEditable={false}
onMouseDown={focusSource}
>
{mermaidPreview.error ? <pre>{mermaidPreview.error.message}</pre> : null}
{mermaidPreview.svg ? <div dangerouslySetInnerHTML={{ __html: mermaidPreview.svg }}></div> : null}
</div>
)}
</>
)
}import type { Extension } from 'prosekit/core'
import { defineReactNodeView, type ReactNodeViewComponent } from 'prosekit/react'
import CodeBlockView from './code-block-view.tsx'
export function defineCodeBlockView(): Extension {
return defineReactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies ReactNodeViewComponent,
})
}'use client'
import { DropIndicator as BaseDropIndicator } from 'prosekit/react/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator.tsx'- 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.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<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/index.ts'
export function defineExtension() {
return union([defineBasicExtension(), defineCodeBlockView()])
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'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.tsx'import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import type { JSX } from 'preact'
import { useMemo, useRef } from 'preact/hooks'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { PreactNodeViewProps } from 'prosekit/preact'
export default function CodeBlockView(props: PreactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language || ''
const hidePreview = props.decorations.some(isCodeBlockPreviewHiddenDecoration)
const preRef = useRef<HTMLPreElement | null>(null)
const showMermaidPreview = !hidePreview && language === 'mermaid'
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
const handleChange = (
event: JSX.TargetedEvent<HTMLSelectElement, Event>,
) => {
setLanguage(event.currentTarget.value)
}
const focusSource = (event: JSX.TargetedMouseEvent<HTMLDivElement>) => {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
const code = props.node.textContent
const mermaidPreview = useMemo(() => {
if (language !== 'mermaid') return { svg: null as string | null, error: null as Error | null }
try {
return { svg: renderMermaidSVG(code, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
}, [code, language])
return (
<>
<div
className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable={false}
data-preview={showMermaidPreview ? '' : undefined}
>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={handleChange}
value={language}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre
ref={(element) => {
props.contentRef(element)
preRef.current = element
}}
className="data-preview:hidden"
data-preview={showMermaidPreview ? '' : undefined}
data-language={language}
></pre>
{showMermaidPreview && (
<div
aria-label="Edit source"
className="block py-2 overflow-auto"
contentEditable={false}
onMouseDown={focusSource}
>
{mermaidPreview.error ? <pre>{mermaidPreview.error.message}</pre> : null}
{mermaidPreview.svg
? <div dangerouslySetInnerHTML={{ __html: mermaidPreview.svg }}></div>
: null}
</div>
)}
</>
)
}import type { Extension } from 'prosekit/core'
import { definePreactNodeView, type PreactNodeViewComponent } from 'prosekit/preact'
import CodeBlockView from './code-block-view.tsx'
export function defineCodeBlockView(): Extension {
return definePreactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies PreactNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/preact/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator.tsx'- 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.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { defineExtension } from './extension.ts'
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/index.ts'
export function defineExtension() {
return union(defineBasicExtension(), defineCodeBlockView())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'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.tsx'import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { SolidNodeViewProps } from 'prosekit/solid'
import { createMemo, For, Show, type JSX } from 'solid-js'
export default function CodeBlockView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as CodeBlockAttrs
const language = () => attrs().language || ''
const hidePreview = () => props.decorations.some(isCodeBlockPreviewHiddenDecoration)
const showMermaidPreview = () => !hidePreview() && language() === 'mermaid'
let preRef: HTMLPreElement | undefined
const mermaidPreview = createMemo<{ svg: string | null; error: Error | null }>(() => {
if (language() !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(props.node.textContent, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
})
const setLanguage = (lang: string) => {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
const focusSource = (event: MouseEvent) => {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preRef?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
return (
<>
<div
class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable={false}
data-preview={showMermaidPreview() ? '' : undefined}
>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language()}
>
<option value="">Plain Text</option>
<For each={shikiBundledLanguagesInfo}>
{(info) => (
<option value={info.id}>
{info.name}
</option>
)}
</For>
</select>
</div>
<pre
ref={(element) => {
props.contentRef(element)
preRef = element
}}
class="data-preview:hidden"
data-preview={showMermaidPreview() ? '' : undefined}
data-language={language()}
></pre>
<Show when={showMermaidPreview()}>
<div
aria-label="Edit source"
class="block py-2 overflow-auto"
contentEditable={false}
onMouseDown={focusSource}
>
<Show when={mermaidPreview().error}>
<pre>{mermaidPreview().error?.message}</pre>
</Show>
<Show when={mermaidPreview().svg}>
<div innerHTML={mermaidPreview().svg || ''}></div>
</Show>
</div>
</Show>
</>
)
}import type { Extension } from 'prosekit/core'
import { defineSolidNodeView, type SolidNodeViewComponent } from 'prosekit/solid'
import CodeBlockView from './code-block-view.tsx'
export function defineCodeBlockView(): Extension {
return defineSolidNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies SolidNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/solid/drop-indicator'
import type { JSX } from 'solid-js'
export default function DropIndicator(): JSX.Element {
return <BaseDropIndicator class="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator.tsx'- 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.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { defineExtension } from './extension.ts'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineExtension()
const defaultContent = untrack(() => props.initialContent ?? sampleContent)
const editor = createEditor({ extension, defaultContent })
</script>
<ProseKit {editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<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/index.ts'
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 { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import type { ProseMirrorNode } from 'prosekit/pm/model'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { Decoration } from 'prosekit/pm/view'
import type { SvelteNodeViewProps } from 'prosekit/svelte'
import { fromStore } from 'svelte/store'
interface Props extends SvelteNodeViewProps {}
const props: Props = $props()
const node: ProseMirrorNode = $derived(fromStore(props.node).current)
const decorations: readonly Decoration[] = $derived(fromStore(props.decorations).current)
const attrs = $derived(node.attrs as CodeBlockAttrs)
const language = $derived(attrs.language || '')
const hidePreview = $derived(decorations.some(isCodeBlockPreviewHiddenDecoration))
const showMermaidPreview = $derived(!hidePreview && language === 'mermaid')
let preElement: HTMLPreElement | null = null
const mermaidPreview = $derived.by<{ svg: string | null; error: Error | null }>(() => {
if (language !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(node.textContent, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
})
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
function bindContentRef(element: HTMLPreElement) {
props.contentRef(element)
preElement = element
}
function focusSource(event: MouseEvent) {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
</script>
<div
class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable="false"
data-preview={showMermaidPreview ? '' : undefined}
>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
value={language}
onchange={(event) => setLanguage((event.target as HTMLSelectElement).value)}
>
<option value="">Plain Text</option>
{#each shikiBundledLanguagesInfo as info (info.id)}
<option value={info.id}>
{info.name}
</option>
{/each}
</select>
</div>
<pre
use:bindContentRef
class="data-preview:hidden"
data-preview={showMermaidPreview ? '' : undefined}
data-language={language}
></pre>
{#if showMermaidPreview}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
aria-label="Edit source"
class="block py-2 overflow-auto"
contentEditable="false"
onmousedown={focusSource}
>
{#if mermaidPreview.error}
<pre>{mermaidPreview.error.message}</pre>
{/if}
{#if mermaidPreview.svg}
<div>{@html mermaidPreview.svg}</div>
{/if}
</div>
{/if}import type { Extension } from 'prosekit/core'
import { defineSvelteNodeView, type SvelteNodeViewComponent } from 'prosekit/svelte'
import CodeBlockView from './code-block-view.svelte'
export function defineCodeBlockView(): Extension {
return defineSvelteNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as SvelteNodeViewComponent,
})
}<script lang="ts">
import { DropIndicator as BaseDropIndicator } from 'prosekit/svelte/drop-indicator'
</script>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />export { default as DropIndicator } from './drop-indicator.svelte'- 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.ts'
import { BlockHandle } from '../../ui/block-handle/index.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { defineExtension } from './extension.ts'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({ extension, defaultContent })
</script>
<template>
<ProseKit :editor="editor">
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<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/index.ts'
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 { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { isCodeBlockPreviewHiddenDecoration, shikiBundledLanguagesInfo, type CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { TextSelection } from 'prosekit/pm/state'
import type { VueNodeViewProps } from 'prosekit/vue'
import { computed, type ComponentPublicInstance } from 'vue'
const props = defineProps<VueNodeViewProps>()
const attrs = computed(() => props.node.value.attrs as CodeBlockAttrs)
const language = computed(() => attrs.value.language || '')
const hidePreview = computed(() => props.decorations.value.some(isCodeBlockPreviewHiddenDecoration))
const showMermaidPreview = computed(() => !hidePreview.value && language.value === 'mermaid')
let preElement: HTMLPreElement | null = null
const mermaidPreview = computed<{ svg: string | null; error: Error | null }>(() => {
if (language.value !== 'mermaid') return { svg: null, error: null }
try {
return { svg: renderMermaidSVG(props.node.value.textContent, THEMES['tokyo-night']), error: null }
} catch (err) {
return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }
}
})
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
function focusSource(event: MouseEvent) {
event.preventDefault()
const pos = props.getPos()
if (typeof pos !== 'number') return
const { state, dispatch } = props.view
const selection = TextSelection.near(state.doc.resolve(pos + 1), 1)
dispatch(state.tr.setSelection(selection))
props.view.focus()
preElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
function bindContentRef(element: Element | ComponentPublicInstance | null, refs: Record<string, unknown>) {
if (typeof props.contentRef === 'function') {
props.contentRef(element, refs)
}
preElement = element instanceof HTMLPreElement ? element : null
}
</script>
<template>
<div
class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden"
contentEditable="false"
:data-preview="showMermaidPreview ? '' : undefined"
>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
:value="language"
@change="(event) => setLanguage((event.target as HTMLSelectElement).value)"
>
<option value="">Plain Text</option>
<option
v-for="info in shikiBundledLanguagesInfo"
:key="info.id"
:value="info.id"
>
{{ info.name }}
</option>
</select>
</div>
<pre
:ref="bindContentRef"
class="data-preview:hidden"
:data-preview="showMermaidPreview ? '' : undefined"
:data-language="language"
></pre>
<div
v-if="showMermaidPreview"
aria-label="Edit source"
class="block py-2 overflow-auto"
contentEditable="false"
@mousedown="focusSource"
>
<pre v-if="mermaidPreview.error">{{ mermaidPreview.error.message }}</pre>
<div v-if="mermaidPreview.svg" v-html="mermaidPreview.svg"></div>
</div>
</template>import type { Extension } from 'prosekit/core'
import { defineVueNodeView, type VueNodeViewComponent } from 'prosekit/vue'
import CodeBlockView from './code-block-view.vue'
export function defineCodeBlockView(): Extension {
return defineVueNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as VueNodeViewComponent,
})
}<script setup lang="ts">
import { DropIndicator as BaseDropIndicator } from 'prosekit/vue/drop-indicator'
</script>
<template>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />
</template>export { default as DropIndicator } from './drop-indicator.vue'- 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.ts'
import { registerLitEditorBlockHandle } from '../../ui/block-handle/index.ts'
import { registerLitEditorDropIndicator } from '../../ui/drop-indicator/index.ts'
import { editorContext } from '../../ui/editor-context.ts'
import { defineExtension } from './extension.ts'
export class LitEditor extends LitElement {
static override properties = {
initialContent: {
attribute: false,
} satisfies PropertyDeclaration<NodeJSON | undefined>,
}
initialContent?: NodeJSON
private editor?: Editor
private ref: Ref<HTMLDivElement>
constructor() {
super()
this.ref = createRef<HTMLDivElement>()
}
override createRenderRoot() {
return this
}
override disconnectedCallback() {
this.editor?.unmount()
super.disconnectedCallback()
}
override willUpdate() {
if (this.editor) {
return
}
const extension = defineExtension()
this.editor = createEditor({
extension,
defaultContent: this.initialContent ?? sampleContent,
})
new ContextProvider(this, {
context: editorContext,
initialValue: this.editor,
})
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties)
this.editor?.mount(this.ref.value)
}
override render() {
return html`<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<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/index.ts'
export function defineExtension() {
return union(defineBasicExtension(), defineCodeBlockView())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { LitEditor as ExampleEditor, registerLitEditor } from './editor.ts'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.ts'
class LitBlockHandle extends LitElement {
declare dir: 'ltr' | 'rtl' | 'auto'
private _editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
}
override createRenderRoot() {
return this
}
override render() {
const placement = this.dir === 'rtl' ? 'right' : 'left'
const editor = this._editorConsumer.value ?? null
return html`<prosekit-block-handle-root .editor=${editor}>
<prosekit-block-handle-positioner .placement=${placement} class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-block-handle-popup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95">
<prosekit-block-handle-add .editor=${editor} class="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-plus size-5 block"></div>
</prosekit-block-handle-add>
<prosekit-block-handle-draggable .editor=${editor} class="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
<div class="i-lucide-grip-vertical size-5 block"></div>
</prosekit-block-handle-draggable>
</prosekit-block-handle-popup>
</prosekit-block-handle-positioner>
</prosekit-block-handle-root>`
}
}
export function registerLitEditorBlockHandle() {
registerBlockHandleAddElement()
registerBlockHandleDraggableElement()
registerBlockHandlePopupElement()
registerBlockHandlePositionerElement()
registerBlockHandleRootElement()
if (customElements.get('lit-editor-block-handle')) return
customElements.define('lit-editor-block-handle', LitBlockHandle)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-block-handle': LitBlockHandle
}
}export { registerLitEditorBlockHandle } from './block-handle.ts'import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { html, render } from 'lit'
import type { Extension } from 'prosekit/core'
import { defineNodeView } from 'prosekit/core'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import {
isCodeBlockPreviewHiddenDecoration,
shikiBundledLanguagesInfo,
} from 'prosekit/extensions/code-block'
import type { ProseMirrorNode } from 'prosekit/pm/model'
import { TextSelection } from 'prosekit/pm/state'
import type { Decoration, EditorView } from 'prosekit/pm/view'
class CodeBlockNodeView {
dom: HTMLElement
contentDOM: HTMLElement
private node: ProseMirrorNode
private view: EditorView
private getPos: () => number | undefined
private decorations: readonly Decoration[]
private wrapper: HTMLDivElement
private pre: HTMLPreElement
private preview: HTMLDivElement
constructor(
node: ProseMirrorNode,
view: EditorView,
getPos: () => number | undefined,
decorations: readonly Decoration[],
) {
this.node = node
this.view = view
this.getPos = getPos
this.decorations = decorations
const root = document.createElement('div')
root.setAttribute('data-node-view-root', 'true')
this.wrapper = document.createElement('div')
this.wrapper.className = 'relative mx-2 top-3 h-0 select-none overflow-visible text-xs data-preview:hidden'
this.wrapper.setAttribute('contenteditable', 'false')
this.pre = document.createElement('pre')
this.pre.className = 'data-preview:hidden'
this.contentDOM = document.createElement('code')
this.contentDOM.setAttribute('data-node-view-content', 'true')
this.contentDOM.style.whiteSpace = 'inherit'
this.pre.appendChild(this.contentDOM)
this.preview = document.createElement('div')
this.preview.className = 'block py-2 overflow-auto'
this.preview.setAttribute('contenteditable', 'false')
this.preview.setAttribute('aria-label', 'Edit source')
this.preview.addEventListener('mousedown', this.handlePreviewMouseDown)
root.appendChild(this.wrapper)
root.appendChild(this.pre)
this.dom = root
this.sync()
}
private handleChange = (event: Event) => {
const language = (event.target as HTMLSelectElement).value
const pos = this.getPos()
if (typeof pos !== 'number') return
const attrs: CodeBlockAttrs = { ...(this.node.attrs as CodeBlockAttrs), language }
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, attrs))
}
private handlePreviewMouseDown = (event: MouseEvent) => {
event.preventDefault()
const pos = this.getPos()
if (typeof pos !== 'number') return
const selection = TextSelection.near(this.view.state.doc.resolve(pos + 1), 1)
this.view.dispatch(this.view.state.tr.setSelection(selection))
this.view.focus()
this.pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
private sync() {
const language = (this.node.attrs as CodeBlockAttrs).language || ''
const hidePreview = this.decorations.some(isCodeBlockPreviewHiddenDecoration)
const showMermaidPreview = !hidePreview && language === 'mermaid'
render(
html`
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
.value=${language}
@change=${this.handleChange}
>
<option value="">Plain Text</option>
${shikiBundledLanguagesInfo.map(
(info) => html`<option value=${info.id}>${info.name}</option>`,
)}
</select>
`,
this.wrapper,
)
if (language) {
this.pre.setAttribute('data-language', language)
} else {
this.pre.removeAttribute('data-language')
}
if (showMermaidPreview) {
this.wrapper.setAttribute('data-preview', '')
this.pre.setAttribute('data-preview', '')
this.renderPreview()
if (!this.preview.isConnected) {
this.dom.appendChild(this.preview)
}
} else {
this.wrapper.removeAttribute('data-preview')
this.pre.removeAttribute('data-preview')
this.preview.replaceChildren()
if (this.preview.isConnected) {
this.preview.remove()
}
}
}
private renderPreview() {
this.preview.replaceChildren()
try {
const svg = renderMermaidSVG(this.node.textContent, THEMES['tokyo-night'])
const svgWrapper = document.createElement('div')
svgWrapper.innerHTML = svg
this.preview.appendChild(svgWrapper)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const errorPre = document.createElement('pre')
errorPre.textContent = message
this.preview.appendChild(errorPre)
}
}
update(node: ProseMirrorNode, decorations: readonly Decoration[]) {
if (node.type !== this.node.type) return false
this.node = node
this.decorations = decorations
this.sync()
return true
}
destroy() {
this.preview.removeEventListener('mousedown', this.handlePreviewMouseDown)
render(null, this.wrapper)
}
}
export function defineCodeBlockView(): Extension {
return defineNodeView({
name: 'codeBlock',
constructor: (node, view, getPos, decorations) =>
new CodeBlockNodeView(node, view, getPos, decorations),
})
}export { defineCodeBlockView } from './code-block-view.ts'import { ContextConsumer } from '@lit/context'
import { html, LitElement } from 'lit'
import { registerDropIndicatorElement } from 'prosekit/lit/drop-indicator'
import { editorContext } from '../editor-context.ts'
class LitDropIndicator extends LitElement {
private _editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
}
override createRenderRoot() {
return this
}
override render() {
return html`<prosekit-drop-indicator
.editor=${this._editorConsumer.value ?? null}
class="z-50 transition-all bg-blue-500"
></prosekit-drop-indicator>`
}
}
export function registerLitEditorDropIndicator() {
registerDropIndicatorElement()
if (customElements.get('lit-editor-drop-indicator')) return
customElements.define('lit-editor-drop-indicator', LitDropIndicator)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-drop-indicator': LitDropIndicator
}
}export { registerLitEditorDropIndicator } from './drop-indicator.ts'import { createContext } from '@lit/context'
import type { Editor } from 'prosekit/core'
export const editorContext = createContext<Editor | undefined>('prosekit-editor')Install
Section titled “Install”npx shadcn@latest add @prosekit/react-ui-drop-indicatornpx shadcn@latest add @prosekit/preact-ui-drop-indicatornpx shadcn@latest add @prosekit/solid-ui-drop-indicatornpx shadcn@latest add @prosekit/svelte-ui-drop-indicatornpx shadcn@latest add @prosekit/vue-ui-drop-indicatorCopy and paste the code from the demo above into your project.
API reference
Section titled “API reference”- prosekit/react/drop-indicator
- prosekit/vue/drop-indicator
- prosekit/preact/drop-indicator
- prosekit/svelte/drop-indicator
- prosekit/solid/drop-indicator
See also
Section titled “See also”- Block handle: the most common source of drag-and-drop in the editor.
fileextension: handles file uploads via drop.