Extensions
ts-medium-editor
features a powerful extension system that allows you to add custom functionality, create new toolbar buttons, and extend the editor's capabilities. This guide covers everything you need to know about creating and using extensions.
Built-in Extensions
Toolbar Extension
The toolbar extension provides the floating toolbar functionality:
import { MediumEditor } from 'ts-medium-editor'
const editor = new MediumEditor('.editable', {
toolbar: {
buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'],
static: false,
sticky: false,
updateOnEmptySelection: false
}
})
Features:
- Contextual toolbar that appears on text selection
- Customizable button set
- Static toolbar option
- Positioning and styling options
Placeholder Extension
The placeholder extension shows placeholder text when the editor is empty:
const editor = new MediumEditor('.editable', {
placeholder: {
text: 'Tell your story...',
hideOnClick: true,
hideOnFocus: false
}
})
Features:
- Customizable placeholder text
- Hide on click or focus options
- Automatic show/hide based on content
Creating Custom Extensions
Extension Interface
All extensions implement the MediumEditorExtension
interface:
import { MediumEditor, MediumEditorExtension } from 'ts-medium-editor'
class CustomExtension implements MediumEditorExtension {
name = 'customExtension'
options: any
constructor(options: any = {}) {
this.options = { ...this.getDefaults(), ...options }
}
getDefaults() {
return {
// Default options
}
}
init() {
// Initialize extension
this.setupEventListeners()
}
destroy() {
// Cleanup when extension is destroyed
this.removeEventListeners()
}
private setupEventListeners() {
// Set up event listeners
}
private removeEventListeners() {
// Remove event listeners
}
}
Note: Extensions implement the MediumEditorExtension
interface, not extend a base class. The interface provides the contract for extension methods.
Extension Lifecycle
Extensions have a defined lifecycle:
- Constructor: Initialize options and state
- init(): Called when editor is initialized
- destroy(): Called when editor is destroyed
Accessing the Editor
Extensions need to receive the editor instance during initialization:
class MyExtension implements MediumEditorExtension {
name = 'myExtension'
private editor: MediumEditor
constructor(editor: MediumEditor, options: any = {}) {
this.editor = editor
}
init() {
// Access editor elements
const elements = this.editor.elements
// Access editor options
const options = this.editor.options
// Subscribe to events
this.editor.subscribe('editableInput', this.handleInput.bind(this))
// Get other extensions
const toolbar = this.editor.getExtensionByName('toolbar')
}
handleInput(event: Event, editable: HTMLElement) {
console.log('Content changed:', editable.innerHTML)
}
}
Note: Unlike some documentation examples, extensions don't automatically have access to this.base
. The editor instance must be passed to the extension during construction.
Example Extensions
Word Counter Extension
Create an extension that counts words and characters:
class WordCounter implements MediumEditorExtension {
name = 'wordCounter'
private editor: MediumEditor
private options: any
private counterElement: HTMLElement | null = null
constructor(editor: MediumEditor, options: any = {}) {
this.editor = editor
this.options = { ...this.getDefaults(), ...options }
}
getDefaults() {
return {
displayWords: true,
displayCharacters: true,
target: null // Element to display counter in
}
}
init() {
this.createCounterElement()
this.editor.subscribe('editableInput', this.updateCount.bind(this))
this.updateCount()
}
destroy() {
if (this.counterElement) {
this.counterElement.remove()
}
}
private createCounterElement() {
this.counterElement = document.createElement('div')
this.counterElement.className = 'medium-editor-word-counter'
this.counterElement.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
`
const target = this.options.target || document.body
target.appendChild(this.counterElement)
}
private updateCount() {
if (!this.counterElement)
return
const content = this.editor.getContent()
const text = this.stripHtml(content)
const words = text.trim() ? text.trim().split(/\s+/).length : 0
const characters = text.length
const display = []
if (this.options.displayWords) {
display.push(`${words} words`)
}
if (this.options.displayCharacters) {
display.push(`${characters} chars`)
}
this.counterElement.textContent = display.join(' • ')
}
private stripHtml(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || ''
}
}
// Usage
const editor = new MediumEditor('.editable')
const wordCounter = new WordCounter(editor, {
displayWords: true,
displayCharacters: true
})
// Add to extensions after creation
editor.options.extensions = {
wordCounter
}
Auto-Save Extension
Create an extension that automatically saves content:
class AutoSave implements MediumEditorExtension {
name = 'autoSave'
private editor: MediumEditor
private options: any
private saveTimeout: number | null = null
private isDirty = false
constructor(editor: MediumEditor, options: any = {}) {
this.editor = editor
this.options = { ...this.getDefaults(), ...options }
}
getDefaults() {
return {
delay: 2000, // 2 seconds
saveCallback: null,
indicator: true
}
}
init() {
this.editor.subscribe('editableInput', this.handleInput.bind(this))
this.editor.subscribe('blur', this.handleBlur.bind(this))
if (this.options.indicator) {
this.createSaveIndicator()
}
}
destroy() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
}
private handleInput() {
this.isDirty = true
this.scheduleAutoSave()
}
private handleBlur() {
if (this.isDirty) {
this.saveNow()
}
}
private scheduleAutoSave() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
this.saveTimeout = window.setTimeout(() => {
this.saveNow()
}, this.options.delay)
}
private async saveNow() {
if (!this.isDirty || !this.options.saveCallback)
return
try {
this.showSaveStatus('saving')
const content = this.editor.getContent()
await this.options.saveCallback(content)
this.isDirty = false
this.showSaveStatus('saved')
}
catch (error) {
this.showSaveStatus('error')
console.error('Auto-save failed:', error)
}
}
private createSaveIndicator() {
// Create save status indicator
}
private showSaveStatus(status: 'saving' | 'saved' | 'error') {
// Update save status indicator
console.log('Save status:', status)
}
}
// Usage
const editor = new MediumEditor('.editable')
const autoSave = new AutoSave(editor, {
delay: 3000,
saveCallback: async (content) => {
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
})
}
})
// Add to extensions after creation
editor.options.extensions = {
autoSave
}
Custom Toolbar Button Extension
Create custom toolbar buttons:
class CustomButtons implements MediumEditorExtension {
name = 'customButtons'
private editor: MediumEditor
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
// Note: Custom buttons are typically added through toolbar configuration
// This example shows how you might extend functionality
this.setupCustomActions()
}
private setupCustomActions() {
// Add custom event handlers
this.editor.subscribe('editableKeydown', this.handleKeydown.bind(this))
}
private handleKeydown(event: KeyboardEvent, editable: HTMLElement) {
// Ctrl/Cmd + Shift + H for highlight
if (event.key === 'H' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
event.preventDefault()
this.wrapSelection('mark')
}
}
private wrapSelection(tagName: string) {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0)
return
const range = selection.getRangeAt(0)
const selectedText = range.toString()
if (selectedText) {
const wrapper = document.createElement(tagName)
wrapper.textContent = selectedText
range.deleteContents()
range.insertNode(wrapper)
// Clear selection
selection.removeAllRanges()
}
}
}
// Usage
const editor = new MediumEditor('.editable', {
toolbar: {
buttons: ['bold', 'italic', 'underline', 'strikethrough', 'code', 'highlight']
}
})
const customButtons = new CustomButtons(editor)
editor.options.extensions = {
customButtons
}
Markdown Support Extension
Add markdown shortcuts:
class MarkdownShortcuts implements MediumEditorExtension {
name = 'markdownShortcuts'
private editor: MediumEditor
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
this.editor.subscribe('editableKeyup', this.handleKeyup.bind(this))
}
private handleKeyup(event: KeyboardEvent, editable: HTMLElement) {
if (event.key === ' ') {
this.checkForMarkdownSyntax(editable)
}
}
private checkForMarkdownSyntax(editable: HTMLElement) {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0)
return
const range = selection.getRangeAt(0)
const textNode = range.startContainer
if (textNode.nodeType !== Node.TEXT_NODE)
return
const text = textNode.textContent || ''
const cursorPos = range.startOffset
// Check for heading syntax
const headingMatch = text.match(/^(#{1,3})\s/)
if (headingMatch && cursorPos > headingMatch[0].length) {
const level = headingMatch[1].length
this.convertToHeading(textNode, level, headingMatch[0].length)
return
}
// Check for bold syntax
const boldMatch = text.match(/\*\*(.*?)\*\*\s$/)
if (boldMatch) {
this.convertToBold(textNode, boldMatch)
return
}
// Check for italic syntax
const italicMatch = text.match(/\*(.*?)\*\s$/)
if (italicMatch) {
this.convertToItalic(textNode, italicMatch)
}
}
private convertToHeading(textNode: Node, level: number, prefixLength: number) {
const parent = textNode.parentElement
if (!parent)
return
const text = (textNode.textContent || '').substring(prefixLength)
const heading = document.createElement(`h${level}`)
heading.textContent = text.trim()
parent.replaceChild(heading, textNode)
// Position cursor at end of heading
const range = document.createRange()
range.selectNodeContents(heading)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
private convertToBold(textNode: Node, match: RegExpMatchArray) {
this.wrapWithTag(textNode, match, 'strong')
}
private convertToItalic(textNode: Node, match: RegExpMatchArray) {
this.wrapWithTag(textNode, match, 'em')
}
private wrapWithTag(textNode: Node, match: RegExpMatchArray, tagName: string) {
const parent = textNode.parentElement
if (!parent)
return
const fullText = textNode.textContent || ''
const beforeText = fullText.substring(0, fullText.indexOf(match[0]))
const afterText = fullText.substring(fullText.indexOf(match[0]) + match[0].length)
// Create new elements
const beforeNode = document.createTextNode(beforeText)
const formattedNode = document.createElement(tagName)
formattedNode.textContent = match[1]
const afterNode = document.createTextNode(afterText)
// Replace original text node
parent.insertBefore(beforeNode, textNode)
parent.insertBefore(formattedNode, textNode)
parent.insertBefore(afterNode, textNode)
parent.removeChild(textNode)
// Position cursor after formatted text
const range = document.createRange()
range.setStartAfter(formattedNode)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
}
// Usage
const editor = new MediumEditor('.editable')
const markdownShortcuts = new MarkdownShortcuts(editor)
editor.options.extensions = {
markdownShortcuts
}
Extension Communication
Inter-Extension Communication
Extensions can communicate with each other through the editor instance:
class ExtensionA implements MediumEditorExtension {
name = 'extensionA'
private editor: MediumEditor
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
// Trigger custom event
this.editor.trigger('extensionA:ready', { data: 'hello' })
}
public doSomething() {
return 'Extension A did something'
}
}
class ExtensionB implements MediumEditorExtension {
name = 'extensionB'
private editor: MediumEditor
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
// Listen for custom event
this.editor.subscribe('extensionA:ready', this.handleExtensionAReady.bind(this))
}
private handleExtensionAReady(data: any, editable: HTMLElement) {
console.log('Extension A is ready:', data)
// Access other extension
const extensionA = this.editor.getExtensionByName('extensionA')
if (extensionA) {
const result = extensionA.doSomething()
console.log(result)
}
}
}
Shared State
Extensions can share state through the editor instance:
class StateManager implements MediumEditorExtension {
name = 'stateManager'
private editor: MediumEditor
private sharedState: Map<string, any> = new Map()
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
// Make state manager available globally
(this.editor as any).stateManager = this
}
setState(key: string, value: any) {
this.sharedState.set(key, value)
this.editor.trigger('stateChanged', { key, value })
}
getState(key: string) {
return this.sharedState.get(key)
}
}
class ConsumerExtension implements MediumEditorExtension {
name = 'consumer'
private editor: MediumEditor
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
this.editor.subscribe('stateChanged', this.handleStateChange.bind(this))
}
private handleStateChange(data: { key: string, value: any }, editable: HTMLElement) {
console.log(`State changed: ${data.key} = ${data.value}`)
}
private updateState() {
const stateManager = (this.editor as any).stateManager
if (stateManager) {
stateManager.setState('myKey', 'myValue')
}
}
}
Extension Best Practices
Performance Considerations
- Debounce expensive operations:
class PerformantExtension implements MediumEditorExtension {
name = 'performantExtension'
private editor: MediumEditor
private debounceTimeout: number | null = null
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
this.editor.subscribe('editableInput', this.handleInput.bind(this))
}
private handleInput() {
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
}
this.debounceTimeout = window.setTimeout(() => {
this.expensiveOperation()
}, 300)
}
private expensiveOperation() {
// Expensive DOM manipulation or API calls
}
}
- Clean up resources:
class CleanExtension implements MediumEditorExtension {
name = 'cleanExtension'
private editor: MediumEditor
private eventListeners: Array<() => void> = []
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
const handler = this.handleEvent.bind(this)
document.addEventListener('click', handler)
this.eventListeners.push(() => {
document.removeEventListener('click', handler)
})
}
destroy() {
this.eventListeners.forEach(cleanup => cleanup())
this.eventListeners = []
}
private handleEvent(event: Event) {
// Handle the event
}
}
Error Handling
Always handle errors gracefully in extensions:
class RobustExtension implements MediumEditorExtension {
name = 'robustExtension'
private editor: MediumEditor
constructor(editor: MediumEditor) {
this.editor = editor
}
init() {
try {
this.setupFeature()
}
catch (error) {
console.error('Extension initialization failed:', error)
// Graceful degradation
}
}
private setupFeature() {
// Feature setup that might fail
}
}
TypeScript Support
Use proper TypeScript types for better development experience:
interface MyExtensionOptions {
enabled: boolean
timeout: number
callback?: (data: any) => void
}
class TypedExtension implements MediumEditorExtension {
name = 'typedExtension'
private editor: MediumEditor
private options: MyExtensionOptions
constructor(editor: MediumEditor, options: Partial<MyExtensionOptions> = {}) {
this.editor = editor
this.options = { ...this.getDefaults(), ...options }
}
getDefaults(): MyExtensionOptions {
return {
enabled: true,
timeout: 1000
}
}
init() {
if (this.options.enabled) {
this.setupFeature()
}
}
private setupFeature() {
setTimeout(() => {
this.options.callback?.({ status: 'ready' })
}, this.options.timeout)
}
}
Publishing Extensions
NPM Package Structure
When publishing extensions as NPM packages:
my-medium-editor-extension/
├── src/
│ └── index.ts
├── dist/
│ ├── index.js
│ └── index.d.ts
├── package.json
├── README.md
└── tsconfig.json
Package.json Example
{
"name": "medium-editor-my-extension",
"version": "1.0.0",
"description": "Custom extension for ts-medium-editor",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"ts-medium-editor": "^1.0.0"
},
"devDependencies": {
"typescript": "^4.5.0",
"ts-medium-editor": "^1.0.0"
}
}
Next Steps
- Check out Multiple Editors for managing multiple instances
- See API Reference for complete method documentation
- Review Usage Guide for all available options
- Explore Multiple Editors for practical implementations