Example: code-block-themes
Code block with multiple syntax highlighting themes.
Install this example with
shadcn: npx shadcn@latest add @prosekit/react-example-code-block-themesnpx shadcn@latest add @prosekit/preact-example-code-block-themesnpx shadcn@latest add @prosekit/solid-example-code-block-themesnpx shadcn@latest add @prosekit/svelte-example-code-block-themesnpx shadcn@latest add @prosekit/vue-example-code-block-themes'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { defineExtension } from './extension.ts'
import Toolbar from './toolbar.tsx'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}import { 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''use client'
import { defineCodeBlockShiki, shikiBundledThemesInfo, type ShikiBundledTheme } from 'prosekit/extensions/code-block'
import { useExtension } from 'prosekit/react'
import { useMemo, useState } from 'react'
export function ThemeSelector() {
const [theme, setTheme] = useState('github-dark')
const extension = useMemo(() => {
return defineCodeBlockShiki({ themes: [theme as ShikiBundledTheme] })
}, [theme])
useExtension(extension)
return (
<>
<label htmlFor="code-block-theme-selector">Theme</label>
<select
id="code-block-theme-selector"
value={theme}
onChange={(event) => setTheme(event.target.value)}
className="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{shikiBundledThemesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.id}
</option>
))}
</select>
</>
)
}'use client'
import { ThemeSelector } from './theme-selector.tsx'
export default function Toolbar() {
return (
<div className="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
<ThemeSelector />
</div>
)
}import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}'use client'
import { 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,
})
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { defineExtension } from './extension.ts'
import Toolbar from './toolbar.tsx'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}import { 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 { JSX } from 'preact'
import { useMemo, useState } from 'preact/hooks'
import { defineCodeBlockShiki, shikiBundledThemesInfo, type ShikiBundledTheme } from 'prosekit/extensions/code-block'
import { useExtension } from 'prosekit/preact'
export function ThemeSelector() {
const [theme, setTheme] = useState('github-dark')
const extension = useMemo(() => {
return defineCodeBlockShiki({ themes: [theme as ShikiBundledTheme] })
}, [theme])
useExtension(extension)
const handleChange = (
event: JSX.TargetedEvent<HTMLSelectElement, Event>,
) => {
setTheme(event.currentTarget.value)
}
return (
<>
<label htmlFor="code-block-theme-selector">Theme</label>
<select
id="code-block-theme-selector"
value={theme}
onChange={handleChange}
className="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{shikiBundledThemesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.id}
</option>
))}
</select>
</>
)
}import { ThemeSelector } from './theme-selector.tsx'
export default function Toolbar() {
return (
<div className="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
<ThemeSelector />
</div>
)
}import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}import { 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 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { defineExtension } from './extension.ts'
import Toolbar from './toolbar.tsx'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const defaultContent = props.initialContent ?? sampleContent
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
return (
<ProseKit editor={editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}import { 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 { defineCodeBlockShiki, shikiBundledThemesInfo, type ShikiBundledTheme } from 'prosekit/extensions/code-block'
import { useExtension } from 'prosekit/solid'
import { createMemo, createSignal, For, type JSX } from 'solid-js'
export function ThemeSelector(): JSX.Element {
const [theme, setTheme] = createSignal('github-dark')
const extension = createMemo(() => {
return defineCodeBlockShiki({ themes: [theme() as ShikiBundledTheme] })
})
useExtension(extension)
return (
<>
<label for="code-block-theme-selector">Theme</label>
<select
id="code-block-theme-selector"
value={theme()}
onChange={(event) => setTheme(event.target.value)}
class="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
<For each={shikiBundledThemesInfo}>
{(info) => (
<option value={info.id}>
{info.id}
</option>
)}
</For>
</select>
</>
)
}import type { JSX } from 'solid-js'
import { ThemeSelector } from './theme-selector.tsx'
export default function Toolbar(): JSX.Element {
return (
<div class="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
<ThemeSelector />
</div>
)
}import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}import { 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,
})
}- examples/code-block-themes/editor.svelte
- examples/code-block-themes/extension.ts
- examples/code-block-themes/index.ts
- examples/code-block-themes/theme-selector.svelte
- examples/code-block-themes/toolbar.svelte
- sample/sample-doc-code-block.ts
- ui/code-block-view/code-block-view.svelte
- ui/code-block-view/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-code-block.ts'
import { defineExtension } from './extension.ts'
import Toolbar from './toolbar.svelte'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineExtension()
const defaultContent = untrack(() => props.initialContent ?? sampleContent)
const editor = createEditor({ extension, defaultContent })
</script>
<ProseKit {editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div {@attach editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>import { 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'<script lang="ts">
import { defineCodeBlockShiki, shikiBundledThemesInfo, type ShikiBundledTheme } from 'prosekit/extensions/code-block'
import { useExtension } from 'prosekit/svelte'
import { toStore } from 'svelte/store'
let theme: ShikiBundledTheme = $state('github-dark')
let extension = $derived(defineCodeBlockShiki({ themes: [theme] }))
useExtension(toStore(() => extension))
function handleChange(event: Event) {
const target = event.target as HTMLSelectElement
theme = target.value as ShikiBundledTheme
}
</script>
<label for="code-block-theme-selector">Theme</label>
<select
id="code-block-theme-selector"
value={theme}
onchange={handleChange}
class="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{#each shikiBundledThemesInfo as info (info.id)}
<option value={info.id}>
{info.id}
</option>
{/each}
</select><script lang="ts">
import ThemeSelector from './theme-selector.svelte'
</script>
<div class="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
<ThemeSelector />
</div>import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}<script lang="ts">
import { 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 setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-code-block.ts'
import { defineExtension } from './extension.ts'
import Toolbar from './toolbar.vue'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({
extension,
defaultContent,
})
</script>
<template>
<ProseKit :editor="editor">
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<Toolbar />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500" />
</div>
</div>
</ProseKit>
</template>import { 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'<script setup lang="ts">
import { defineCodeBlockShiki, shikiBundledThemesInfo, type ShikiBundledTheme } from 'prosekit/extensions/code-block'
import { useExtension } from 'prosekit/vue'
import { computed, ref } from 'vue'
const theme = ref<ShikiBundledTheme>('github-dark')
const extension = computed(() => {
return defineCodeBlockShiki({ themes: [theme.value] })
})
useExtension(extension)
</script>
<template>
<label for="code-block-theme-selector">Theme</label>
<select
id="code-block-theme-selector"
v-model="theme"
class="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
<option
v-for="info in shikiBundledThemesInfo"
:key="info.id"
:value="info.id"
>
{{ info.id }}
</option>
</select>
</template><script setup lang="ts">
import ThemeSelector from './theme-selector.vue'
</script>
<template>
<div class="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center">
<ThemeSelector />
</div>
</template>import type { NodeJSON } from 'prosekit/core'
const js = `
async function start() {
while (true) {
await sleep()
await eat()
await code('JavaScript!')
}
}
`.trim()
const py = `
async def start():
while True:
await sleep()
await eat()
await code('Python!')
`.trim()
const go = `
func start() {
for {
sleep()
eat()
code('Go!')
}
}
`.trim()
const flowchart = [
'graph TD',
' A[Start] --> B{Is it working?}',
' B -->|Yes| C[Great!]',
' B -->|No| D[Debug]',
' D --> B',
' C --> E[End]',
].join('\n')
const sequence = [
'sequenceDiagram',
' Alice->>Bob: Hello Bob!',
' Bob-->>Alice: Hi Alice!',
' Alice->>Bob: How are you?',
' Bob-->>Alice: Great, thanks!',
].join('\n')
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [{ type: 'text', text: js }],
},
{
type: 'codeBlock',
attrs: { language: 'python' },
content: [{ type: 'text', text: py }],
},
{
type: 'codeBlock',
attrs: { language: 'go' },
content: [{ type: 'text', text: go }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: flowchart }],
},
{
type: 'codeBlock',
attrs: { language: 'mermaid' },
content: [{ type: 'text', text: sequence }],
},
],
}<script setup lang="ts">
import { 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,
})
}