Custom Blocks & Marks
Blockslides allows you to extend the editor with custom blocks and marks. Blocks are structural elements like paragraphs or headings, while marks are inline formatting like bold or italic. Creating custom extensions lets you add domain-specific content types tailored to your presentation needs.
Blocks vs Marks
Blocks are nodes that define structure (paragraphs, headings, custom cards). Marks are inline formatting applied to text (bold, italic, custom highlights). Learn more in What are blocks?
Creating Custom Blocks
Custom blocks extend the Node type from @blockslides/core. They define structural elements that can contain content.
Basic Block Structure
A minimal custom block includes a name, group, content model, and rendering logic:
import { Node, mergeAttributes } from '@blockslides/core'
export const CustomBlock = Node.create({
name: 'customBlock',
group: 'block',
content: 'inline*',
parseHTML() {
return [{ tag: 'div[data-type="custom"]' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes({ 'data-type': 'custom' }, HTMLAttributes),
0
]
}
})Key properties:
- name - Unique identifier for this block type
- group - Typically
'block'for block-level elements - content - Content expression defining what can go inside (e.g.,
'inline*','block+','paragraph+') - parseHTML - Rules for parsing HTML into this block
- renderHTML - How to render this block as HTML/DOM
Using JSX for Rendering
Blockslides supports JSX syntax for renderHTML, which provides a cleaner and more readable alternative to array syntax. To use JSX, add the JSX import source pragma at the top of your file:
/** @jsxImportSource @blockslides/core */
import { Node, mergeAttributes } from '@blockslides/core'
export const CustomBlock = Node.create({
name: 'customBlock',
group: 'block',
content: 'inline*',
parseHTML() {
return [{ tag: 'div[data-type="custom"]' }]
},
renderHTML({ HTMLAttributes }) {
return (
<div {...mergeAttributes({ 'data-type': 'custom' }, HTMLAttributes)}>
<slot />
</div>
)
}
})Both syntaxes are supported:
- Array syntax:
['div', { class: 'foo' }, 0]- Use0to mark where content goes - JSX syntax:
<div class="foo"><slot /></div>- Use<slot />to mark where content goes
JSX vs Array Syntax
JSX syntax is preferred in the BlockSlides codebase for better readability and type safety. Use <slot /> in JSX where you would use 0 in array syntax to indicate where child content should be rendered.
Adding Attributes
Blocks can have attributes to store configuration and state:
export const CalloutBlock = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
addAttributes() {
return {
type: {
default: 'info',
parseHTML: element => element.getAttribute('data-callout-type'),
renderHTML: attributes => {
return { 'data-callout-type': attributes.type }
}
},
title: {
default: null,
parseHTML: element => element.getAttribute('data-title')
}
}
},
parseHTML() {
return [{ tag: 'div.callout' }]
},
renderHTML({ node, HTMLAttributes }) {
return [
'div',
mergeAttributes(
{ class: 'callout' },
HTMLAttributes
),
node.attrs.title ? ['div', { class: 'callout-title' }, node.attrs.title] : null,
['div', { class: 'callout-content' }, 0]
].filter(Boolean)
}
})Attribute configuration:
- default - Default value when attribute is not specified
- parseHTML - Extract attribute value from HTML element
- renderHTML - Add attribute to rendered HTML
- rendered - Set to
falseto exclude from HTML output (only in document model)
Adding Commands
Commands provide programmatic control over your block:
declare module '@blockslides/core' {
interface Commands<ReturnType> {
callout: {
setCallout: (attributes?: { type?: string; title?: string }) => ReturnType
toggleCallout: () => ReturnType
updateCallout: (attributes: { type?: string; title?: string }) => ReturnType
}
}
}
export const CalloutBlock = Node.create({
name: 'callout',
// ... other configuration ...
addCommands() {
return {
setCallout:
(attributes = {}) =>
({ commands }) => {
return commands.setNode(this.name, attributes)
},
toggleCallout:
() =>
({ commands }) => {
return commands.toggleNode(this.name, 'paragraph')
},
updateCallout:
(attributes) =>
({ commands }) => {
return commands.updateAttributes(this.name, attributes)
}
}
}
})Commands are called via editor.commands.setCallout() and provide chainable operations on the editor state.
Keyboard Shortcuts
Add keyboard shortcuts for quick access:
export const CalloutBlock = Node.create({
name: 'callout',
// ... other configuration ...
addKeyboardShortcuts() {
return {
'Mod-Shift-i': () => this.editor.commands.toggleCallout(),
'Escape': () => {
// Custom behavior when inside a callout
if (this.editor.isActive('callout')) {
return this.editor.commands.lift('callout')
}
return false
}
}
}
})Input Rules
Input rules enable markdown-like shortcuts:
import { Node, wrappingInputRule } from '@blockslides/core'
export const CalloutBlock = Node.create({
name: 'callout',
// ... other configuration ...
addInputRules() {
return [
wrappingInputRule({
find: /^:::\s$/,
type: this.type,
getAttributes: () => ({ type: 'info' })
})
]
}
})This allows users to type ::: followed by space to create a callout block.
Configuration Options
Make your block configurable:
export interface CalloutOptions {
types: string[]
HTMLAttributes: Record<string, any>
defaultType: string
}
export const CalloutBlock = Node.create<CalloutOptions>({
name: 'callout',
addOptions() {
return {
types: ['info', 'warning', 'error', 'success'],
HTMLAttributes: {},
defaultType: 'info'
}
},
addAttributes() {
return {
type: {
default: this.options.defaultType,
parseHTML: element => {
const type = element.getAttribute('data-callout-type')
return this.options.types.includes(type) ? type : this.options.defaultType
}
}
}
}
})Configure when instantiating:
import { CalloutBlock } from './callout'
const editor = useSlideEditor({
extensions: [
CalloutBlock.configure({
types: ['tip', 'warning'],
defaultType: 'tip'
})
]
})Advanced: Drag and Drop
Make blocks draggable:
export const CustomCard = Node.create({
name: 'customCard',
group: 'block',
content: 'block+',
draggable: true, // Enable drag and drop
selectable: true, // Make it selectable
atom: true, // Treat as a single unit (no editing inside)
// ... rest of configuration ...
})Advanced: CSS Injection
Inject styles for your custom block using the onCreate lifecycle hook:
import { Node, mergeAttributes } from '@blockslides/core'
export interface CustomBlockOptions {
HTMLAttributes: Record<string, any>
injectCSS: boolean
styles: string
}
export const CustomBlock = Node.create<CustomBlockOptions>({
name: 'customBlock',
addOptions() {
return {
HTMLAttributes: {},
injectCSS: true,
styles: `
.custom-block {
border: 2px solid #4a90e2;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.custom-block[data-variant="danger"] {
border-color: #e74c3c;
}
`
}
},
content: 'block+',
group: 'block',
onCreate() {
if (!this.options.injectCSS || typeof document === 'undefined') {
return
}
const styleId = 'blockslides-custom-block-styles'
if (document.getElementById(styleId)) {
return
}
const style = document.createElement('style')
style.id = styleId
style.textContent = this.options.styles.trim()
document.head.appendChild(style)
},
parseHTML() {
return [{ tag: 'div.custom-block' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes({ class: 'custom-block' }, this.options.HTMLAttributes, HTMLAttributes),
0
]
}
})Creating Custom Marks
Custom marks extend the Mark type from @blockslides/core. They apply inline formatting to text.
Basic Mark Structure
A minimal custom mark using array syntax:
import { Mark, mergeAttributes } from '@blockslides/core'
export const Highlight = Mark.create({
name: 'highlight',
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', mergeAttributes(HTMLAttributes), 0]
}
})Or using JSX syntax (preferred):
/** @jsxImportSource @blockslides/core */
import { Mark, mergeAttributes } from '@blockslides/core'
export const Highlight = Mark.create({
name: 'highlight',
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return (
<mark {...mergeAttributes(HTMLAttributes)}>
<slot />
</mark>
)
}
})Adding Mark Attributes
Marks can have attributes for customization:
export const Highlight = Mark.create({
name: 'highlight',
addAttributes() {
return {
color: {
default: 'yellow',
parseHTML: element => element.getAttribute('data-color'),
renderHTML: attributes => {
if (!attributes.color) return {}
return {
'data-color': attributes.color,
style: `background-color: ${attributes.color}`
}
}
}
}
},
parseHTML() {
return [
{ tag: 'mark' },
{
style: 'background-color',
getAttrs: value => {
return { color: value }
}
}
]
},
renderHTML({ HTMLAttributes }) {
return ['mark', mergeAttributes(HTMLAttributes), 0]
}
})Mark Commands
Add commands to control your mark:
declare module '@blockslides/core' {
interface Commands<ReturnType> {
highlight: {
setHighlight: (attributes?: { color?: string }) => ReturnType
toggleHighlight: (attributes?: { color?: string }) => ReturnType
unsetHighlight: () => ReturnType
}
}
}
export const Highlight = Mark.create({
name: 'highlight',
// ... other configuration ...
addCommands() {
return {
setHighlight:
(attributes = {}) =>
({ commands }) => {
return commands.setMark(this.name, attributes)
},
toggleHighlight:
(attributes = {}) =>
({ commands }) => {
return commands.toggleMark(this.name, attributes)
},
unsetHighlight:
() =>
({ commands }) => {
return commands.unsetMark(this.name)
}
}
}
})Mark Input Rules
Enable markdown-style shortcuts for marks:
import { Mark, markInputRule } from '@blockslides/core'
// Match ==highlighted text==
export const inputRegex = /(?:^|\s)(==(?!\s+==)((?:[^=]+))==(?!\s+==))$/
export const Highlight = Mark.create({
name: 'highlight',
// ... other configuration ...
addInputRules() {
return [
markInputRule({
find: inputRegex,
type: this.type
})
]
}
})Mark Paste Rules
Handle pasted content:
import { Mark, markPasteRule } from '@blockslides/core'
// Match ==highlighted text== when pasting
export const pasteRegex = /(?:^|\s)(==(?!\s+==)((?:[^=]+))==(?!\s+==))/g
export const Highlight = Mark.create({
name: 'highlight',
// ... other configuration ...
addPasteRules() {
return [
markPasteRule({
find: pasteRegex,
type: this.type
})
]
}
})Mark Behavior Options
Control mark behavior with these options:
export const Highlight = Mark.create({
name: 'highlight',
// Keep mark when splitting a node (e.g., pressing Enter)
keepOnSplit: true,
// Whether this mark is inclusive at the boundaries
// If true, typing at the edge extends the mark
inclusive() {
return true
},
// Marks that this mark excludes (can't coexist with)
excludes() {
return 'code' // Can't highlight code marks
},
// ... other configuration ...
})Mark Keyboard Shortcuts
export const Highlight = Mark.create({
name: 'highlight',
// ... other configuration ...
addKeyboardShortcuts() {
return {
'Mod-Shift-h': () => this.editor.commands.toggleHighlight(),
'Mod-Shift-H': () => this.editor.commands.toggleHighlight({ color: 'red' })
}
}
})Configuration Options
export interface HighlightOptions {
colors: string[]
defaultColor: string
HTMLAttributes: Record<string, any>
}
export const Highlight = Mark.create<HighlightOptions>({
name: 'highlight',
addOptions() {
return {
colors: ['yellow', 'green', 'blue', 'red'],
defaultColor: 'yellow',
HTMLAttributes: {}
}
},
addAttributes() {
return {
color: {
default: this.options.defaultColor,
parseHTML: element => {
const color = element.getAttribute('data-color')
return this.options.colors.includes(color) ? color : this.options.defaultColor
}
}
}
}
})Export types for better developer experience:
export interface CalloutAttributes {
type: 'info' | 'warning' | 'error' | 'success'
title?: string | null
}
export interface CalloutOptions {
types: string[]
HTMLAttributes: Record<string, any>
defaultType: string
}
export const CalloutBlock = Node.create<CalloutOptions>({
name: 'callout',
// ... configuration ...
})
// Usage with type safety
editor.commands.setCallout({
type: 'warning', // ✓ Type-checked
title: 'Alert'
})