@snapblog/editor
v0.0.34
Published
The core editor for Snapblog.
Readme
@snapblog/editor
Version: 0.0.33
Mission: Transform Snapblog Editor into the premier TypeScript-native rich text editor that surpasses TipTap, Lexical, and Quill.
Table of Contents
- Overview
- Architecture
- Installation & Setup
- Core Components
- Extension System
- Available Extensions
- API Reference
- Hooks
- Type System
- Development Guide
- Testing
- Performance
Overview
@snapblog/editor is a powerful, TypeScript-native rich text editor built on top of ProseMirror. It provides a modular extension system, React integration, and a comprehensive API for building sophisticated text editing experiences.
Key Features
- TypeScript-First: Full type safety with comprehensive type definitions
- Modular Architecture: Extension-based system for maximum flexibility
- React Integration: Native React components and hooks
- ProseMirror Foundation: Built on the robust ProseMirror framework
- Command System: Type-safe command registry with chainable API
- State Management: Centralized state management with EditorController
- Event System: Comprehensive event handling and subscription
- Testing Support: Built-in testing utilities and Jest configuration
Architecture
@snapblog/editor/
├── core/ # Core editor functionality
│ ├── Editor.ts # Main editor class
│ ├── extension-system/ # Extension management
│ ├── models/ # Core models (CommandManager, EventManager)
│ ├── store/ # State management (EditorController)
│ ├── types/ # TypeScript definitions
│ ├── utils/ # Utility functions
│ └── testing/ # Testing utilities
├── components/ # React components
├── contexts/ # React context providers
├── extensions/ # Built-in extensions
└── hooks/ # React hooksCore Principles
- Modularity: Everything is an extension
- Type Safety: Comprehensive TypeScript support
- Performance: Optimized for large documents
- Extensibility: Easy to extend and customize
- Developer Experience: Intuitive API and excellent tooling
Installation & Setup
Installation
npm install @snapblog/editorPeer Dependencies
npm install react@^19.1.0 react-dom@^19.1.0Basic Setup
import React from 'react';
import { EditorProvider, EditorComponent } from '@snapblog/editor';
import { BoldExtension, ItalicExtension, ParagraphExtension } from '@snapblog/editor';
function App() {
const config = {
extensions: [
new ParagraphExtension(),
new BoldExtension(),
new ItalicExtension(),
],
content: '<p>Hello, world!</p>',
};
return (
<EditorProvider config={config}>
<EditorComponent />
</EditorProvider>
);
}Core Components
Editor Class
The main Editor class is the heart of the editor system.
import { Editor, EditorConfig } from '@snapblog/editor';
const config: EditorConfig = {
element: document.getElementById('editor'),
extensions: [/* extensions */],
content: 'Initial content',
};
const editor = new Editor(config);Key Properties
view: EditorView- ProseMirror editor viewstate: EditorState- Current editor stateschema: Schema- Document schemacommands- Type-safe command registrychain- Chainable command API$state- State inspection utilitieshistory- Undo/redo functionalityplugins- Plugin management
Key Methods
executeCommand(command)- Execute a commandsubscribe(event, callback)- Subscribe to eventsgetHTML()- Get HTML contentgetJSON()- Get JSON representationsetContent(content)- Set editor contentdestroy()- Clean up resources
EditorProvider
React context provider that manages the editor instance.
interface EditorProviderProps {
children: React.ReactNode;
config: Omit<EditorConfig, 'element'>;
onReady?: (editor: EditorInstance) => void;
onError?: (error: Error) => void;
}EditorComponent
Simple React component that renders the editor container.
const EditorComponent = forwardRef<HTMLDivElement>((props, ref) => {
return <div ref={ref} id="editor-container" {...props} />;
});Extension System
BaseExtension
All extensions inherit from the BaseExtension class:
export abstract class BaseExtension<Options = any, Storage = any> {
abstract get name(): string;
// Schema methods
addNodes(): Record<string, NodeSpec> { return {}; }
addMarks(): Record<string, MarkSpec> { return {}; }
// Interaction methods
addKeymap(schema: Schema): Keymap { return {}; }
addInputRules(schema: Schema): InputRule[] { return []; }
addProseMirrorPlugins(schema: Schema): Plugin[] { return []; }
// Command and state methods
addCommands(schema: Schema): Record<string, any> { return {}; }
addState(schema: Schema): Record<string, any> { return {}; }
// Lifecycle methods
async onInit(schema: Schema): Promise<void> {}
async onDestroy(): Promise<void> {}
// Configuration methods
withShortcuts(shortcuts: string[]): this
withInputRules(rules: string[]): this
withHTMLAttributes(attrs: Record<string, any>): this
withDependencies(deps: string[]): this
}Extension Types
Mark Extensions (Inline Formatting)
Marks are inline formatting that can be applied to text ranges:
import { toggleMark } from 'prosemirror-commands';
import { BaseExtension } from '@snapblog/editor';
// Extend the Commands interface
declare module "@snapblog/editor" {
interface Commands<ReturnType> {
highlight: {
toggle: (options?: { color?: string }) => ReturnType;
setColor: (options: { color: string }) => ReturnType;
unset: () => ReturnType;
}
}
}
export class HighlightExtension extends BaseExtension {
get name() {
return 'highlight';
}
addMarks() {
return {
highlight: {
attrs: {
color: { default: '#ffff00' },
},
parseDOM: [
{
tag: 'mark[data-color]',
getAttrs: (node) => ({
color: (node as HTMLElement).getAttribute('data-color'),
}),
},
],
toDOM: (mark) => [
'mark',
{
'data-color': mark.attrs.color,
style: `background-color: ${mark.attrs.color}`,
},
0,
],
},
};
}
addCommands(schema) {
return {
highlight: {
toggle: (options = {}) => toggleMark(schema.marks.highlight, {
color: options.color || '#ffff00',
}),
setColor: (options) => toggleMark(schema.marks.highlight, {
color: options.color,
}),
unset: () => toggleMark(schema.marks.highlight),
},
};
}
addKeymap(schema) {
return {
'Mod-Shift-h': this.addCommands(schema).highlight.toggle(),
};
}
}Node Extensions (Block Elements)
Nodes are block-level or inline elements that form the document structure:
import { setBlockType } from 'prosemirror-commands';
import { textblockTypeInputRule } from 'prosemirror-inputrules';
import { BaseExtension } from '@snapblog/editor';
declare module "@snapblog/editor" {
interface Commands<ReturnType> {
callout: {
set: (options?: { type?: 'info' | 'warning' | 'error' }) => ReturnType;
toggle: (options?: { type?: 'info' | 'warning' | 'error' }) => ReturnType;
}
}
}
export class CalloutExtension extends BaseExtension {
get name() {
return 'callout';
}
addNodes() {
return {
callout: {
attrs: {
type: { default: 'info' },
},
content: 'block+',
group: 'block',
defining: true,
parseDOM: [
{
tag: 'div[data-callout]',
getAttrs: (node) => ({
type: (node as HTMLElement).getAttribute('data-type') || 'info',
}),
},
],
toDOM: (node) => [
'div',
{
'data-callout': '',
'data-type': node.attrs.type,
class: `callout callout-${node.attrs.type}`,
},
0,
],
},
};
}
addCommands(schema) {
return {
callout: {
set: (options = {}) => setBlockType(schema.nodes.callout, {
type: options.type || 'info',
}),
toggle: (options = {}) => (state, dispatch) => {
const { $from } = state.selection;
const currentNode = $from.node();
if (currentNode.type === schema.nodes.callout) {
return setBlockType(schema.nodes.paragraph)(state, dispatch);
} else {
return setBlockType(schema.nodes.callout, {
type: options.type || 'info',
})(state, dispatch);
}
},
},
};
}
addInputRules(schema) {
return [
textblockTypeInputRule(
/^:::(info|warning|error)\s$/,
schema.nodes.callout,
(match) => ({ type: match[1] })
),
];
}
addKeymap(schema) {
return {
'Mod-Shift-c': this.addCommands(schema).callout.toggle(),
};
}
}Extension with Options and Storage
interface MyExtensionOptions {
enabled: boolean;
color: string;
shortcuts: string[];
}
interface MyExtensionStorage {
count: number;
lastUsed: Date;
}
export class MyExtension extends BaseExtension<MyExtensionOptions, MyExtensionStorage> {
get name() {
return 'myExtension';
}
constructor(options: Partial<MyExtensionOptions> = {}) {
super({
enabled: true,
color: '#000000',
shortcuts: ['Mod-m'],
...options,
});
}
addStorage(): MyExtensionStorage {
return {
count: 0,
lastUsed: new Date(),
};
}
}Available Extensions
Text Formatting
- BoldExtension - Bold text formatting (
Mod-b) - ItalicExtension - Italic text formatting (
Mod-i) - UnderlineExtension - Underline text formatting (
Mod-u) - StrikethroughExtension - Strikethrough text formatting
- HighlightExtension - Text highlighting
- SubscriptExtension - Subscript formatting
- SuperscriptExtension - Superscript formatting
Structure
- ParagraphExtension - Basic paragraph support
- HeadingExtension - Heading levels (H1-H6)
- HardBreakExtension - Line breaks (
Shift-Enter) - ListsExtension - Ordered and unordered lists
Advanced
- LinkExtension - Link support with URL validation
- CodeBlockExtension - Code block formatting
- HistoryExtension - Undo/redo functionality
- DropCursorExtension - Visual drop cursor
- BaseKeymapExtension - Basic keyboard shortcuts
- CustomStateExtension - Custom state management
Usage Example
import {
ParagraphExtension,
BoldExtension,
ItalicExtension,
HeadingExtension,
ListsExtension,
LinkExtension,
HistoryExtension,
} from '@snapblog/editor';
const extensions = [
new ParagraphExtension(),
new BoldExtension(),
new ItalicExtension(),
new HeadingExtension({ levels: [1, 2, 3] }),
new ListsExtension(),
new LinkExtension({ openOnClick: true }),
new HistoryExtension({ depth: 100 }),
];API Reference
Commands API
The editor provides a type-safe command system:
// Direct command execution
editor.commands.bold.toggle();
editor.commands.heading.setLevel({ level: 2 });
// Chainable commands
editor.chain()
.bold.toggle()
.italic.toggle()
.run();
// Command availability checking
if (editor.can.bold.toggle()) {
editor.commands.bold.toggle();
}
// Active state checking
const isBold = editor.isActive.bold();
const isHeading = editor.isActive.heading({ level: 2 });State API
// State inspection
const currentState = editor.$state.current();
const selection = editor.$state.selection();
const doc = editor.$state.doc();
// State utilities
const isEmpty = editor.$state.isEmpty();
const canUndo = editor.$state.canUndo();
const canRedo = editor.$state.canRedo();Content API
// Get content
const html = editor.getHTML();
const json = editor.getJSON();
// Set content
editor.setContent('<p>New content</p>');
editor.setContent({ type: 'doc', content: [/* nodes */] });
// Clear content
editor.commands.clearContent();Event API
// Subscribe to events
const unsubscribe = editor.subscribe('update', ({ editor, transaction }) => {
console.log('Editor updated', { editor, transaction });
});
// Unsubscribe
unsubscribe();
// Available events
// - 'update' - Content changed
// - 'selectionUpdate' - Selection changed
// - 'focus' - Editor focused
// - 'blur' - Editor blurred
// - 'destroy' - Editor destroyedHooks
useEditorController
Access the EditorController instance:
import { useEditorController } from '@snapblog/editor';
function MyComponent() {
const controller = useEditorController();
const editor = controller.getEditor();
return (
<button onClick={() => editor?.commands.bold.toggle()}>
Toggle Bold
</button>
);
}useEditorState
Subscribe to editor state changes:
import { useEditorState } from '@snapblog/editor';
function MyComponent() {
const { editor, isReady, errors } = useEditorState();
if (!isReady) return <div>Loading...</div>;
if (errors.length > 0) return <div>Error: {errors[0].message}</div>;
return <div>Editor is ready!</div>;
}Type System
Command Types
interface Commands<ReturnType> {
bold: {
toggle: () => ReturnType;
};
italic: {
toggle: () => ReturnType;
};
heading: {
setLevel: (options: { level: number }) => ReturnType;
};
// ... other commands
}
interface TypeSafeCommandRegistry {
commands: Commands<boolean>;
can: Commands<boolean>;
isActive: Commands<boolean>;
chain: () => ChainedCommands;
$state: StateAPI;
history: HistoryAPI;
plugins: PluginAPI;
}Extension State Types
interface ExtensionStates {
bold: {
isActive: boolean;
canEnable: boolean;
canDisable: boolean;
attributes: Record<string, any>;
};
// ... other extension states
}Extension Development
Development Workflow
Create Extension File
# Create your extension file touch src/extensions/MyExtension.tsImplement Extension
import { BaseExtension } from '@snapblog/editor'; export class MyExtension extends BaseExtension { get name() { return 'myExtension'; } // Add your implementation }Export Extension
// In src/extensions/index.ts export { MyExtension } from './MyExtension';Test Extension
import { createTestEditor } from '@snapblog/editor/testing'; import { MyExtension } from './MyExtension'; const editor = createTestEditor({ extensions: [new MyExtension()], });
Extension Best Practices
1. Type Safety
Always extend the Commands and ExtensionStates interfaces:
declare module "@snapblog/editor" {
interface Commands<ReturnType> {
myExtension: {
toggle: () => ReturnType;
}
}
interface ExtensionStates {
myExtension: {
isActive: boolean;
canToggle: boolean;
}
}
}2. Error Handling
export class MyExtension extends BaseExtension {
addCommands(schema) {
return {
myExtension: {
toggle: () => (state, dispatch) => {
try {
// Your command logic
return true;
} catch (error) {
console.error('MyExtension command failed:', error);
return false;
}
},
},
};
}
}3. Performance Optimization
export class MyExtension extends BaseExtension {
// Cache expensive computations
private cachedSchema: Schema | null = null;
private cachedCommands: any = null;
addCommands(schema) {
if (this.cachedSchema === schema && this.cachedCommands) {
return this.cachedCommands;
}
this.cachedSchema = schema;
this.cachedCommands = {
// Your commands
};
return this.cachedCommands;
}
}4. Lifecycle Management
export class MyExtension extends BaseExtension {
private eventListeners: (() => void)[] = [];
async onInit(schema: Schema): Promise<void> {
// Setup resources
const cleanup = this.setupEventListener();
this.eventListeners.push(cleanup);
}
async onDestroy(): Promise<void> {
// Cleanup resources
this.eventListeners.forEach(cleanup => cleanup());
this.eventListeners = [];
}
}Testing Extensions
Unit Testing
import { createTestEditor } from '@snapblog/editor/testing';
import { MyExtension } from '../MyExtension';
describe('MyExtension', () => {
let editor: any;
beforeEach(() => {
editor = createTestEditor({
extensions: [new MyExtension()],
content: '<p>Test content</p>',
});
});
afterEach(() => {
editor?.destroy();
});
it('should toggle correctly', () => {
expect(editor.isActive.myExtension()).toBe(false);
editor.commands.myExtension.toggle();
expect(editor.isActive.myExtension()).toBe(true);
editor.commands.myExtension.toggle();
expect(editor.isActive.myExtension()).toBe(false);
});
});Integration Testing
import { render, screen } from '@testing-library/react';
import { EditorProvider, EditorComponent } from '@snapblog/editor';
import { MyExtension } from '../MyExtension';
test('MyExtension works in React component', () => {
const config = {
extensions: [new MyExtension()],
content: '<p>Test</p>',
};
render(
<EditorProvider config={config}>
<EditorComponent />
</EditorProvider>
);
// Test your extension behavior
});Troubleshooting
Common Issues
Extension Not Loading
- Check if extension is properly exported
- Verify extension name is unique
- Ensure no circular dependencies
Commands Not Working
- Verify Commands interface is extended
- Check command return types
- Ensure schema is passed correctly
Type Errors
- Make sure to declare module augmentations
- Check TypeScript configuration
- Verify import paths
Performance Issues
- Profile extension methods
- Cache expensive operations
- Avoid heavy computations in render cycle
Development Guide
Project Structure
src/
├── core/ # Core functionality
│ ├── Editor.ts # Main editor class
│ ├── extension-system/ # Extension base classes
│ ├── models/ # Core models
│ ├── store/ # State management
│ ├── types/ # Type definitions
│ └── utils/ # Utilities
├── components/ # React components
├── contexts/ # React contexts
├── extensions/ # Built-in extensions
├── hooks/ # React hooks
└── index.ts # Main exportBuilding
# Build the package
npm run build
# The build outputs to dist/
# - dist/index.es.js (ES modules)
# - dist/index.umd.js (UMD)
# - dist/index.d.ts (TypeScript definitions)Development Workflow
- Make changes to source files
- Run tests:
npm test - Build:
npm run build - Test in snapblog package
Testing
Test Structure
__tests__/
├── Editor.test.ts # Core editor tests
└── custom-nodes.test.tsx # Custom node testsRunning Tests
# Run all tests
npm test
# Run tests in watch mode
npm test -- --watch
# Run tests with coverage
npm test -- --coverageTest Utilities
The editor provides testing utilities in core/testing/:
import { createTestEditor } from '@snapblog/editor/testing';
const editor = createTestEditor({
extensions: [new BoldExtension()],
content: '<p>Test content</p>',
});
// Test commands
editor.commands.bold.toggle();
expect(editor.isActive.bold()).toBe(true);Performance
Optimization Strategies
- Lazy Loading: Extensions are loaded on-demand
- Memoization: React components use proper memoization
- Efficient Updates: Only necessary DOM updates are performed
- Plugin Optimization: Plugins are optimized for performance
Performance Monitoring
// Monitor editor performance
editor.subscribe('update', ({ transaction }) => {
console.log('Transaction time:', transaction.time);
});
// Check document size
const docSize = editor.$state.doc().nodeSize;
console.log('Document size:', docSize);Best Practices
- Minimize Extensions: Only include necessary extensions
- Optimize Content: Use efficient content structures
- Batch Updates: Group multiple changes into single transactions
- Memory Management: Properly destroy editor instances
Contributing
See the main project's IMPROVEMENT_CHECKLIST.md for current development priorities and contribution guidelines.
License
See the main project's LICENSE file.
