Edytor aims to be the premier rich text editor for Svelte, providing the same level of power, flexibility and extensibility that Slate.js offers for React. Like Slate.js, Edytor strives to be heavily customizable and provide a powerful API to build any kind of collaborative rich text editor.
Edytor is currently in the early stages of development. It is not yet ready for production use. I welcome early contributors to help us build a better editor for Svelte. Just run it, see what you can do with it, and open issues or PRs.
If you want to submit an issue please share the json value of the document. It will help understand and fix the issue.
- 📑 Customizable with snippets: Use snippets to render your own blocks and marks
- 🎨 Rich Text Formatting: Full support for marks, blocks and inline blocks.
- 🤝 Real-time Collaboration: Uses Y.js as data store, collaborative editing is built-in
- 🔌 Plugin System: Extensible architecture for custom features. I try to make every action performed by the editor hackable and preventable to let you build your own features.
- ⚡ High Performance: Optimized for large documents, fine grained update at the leaf level thanks to Y.js and Svelte's reactivity
- 🔄 Undo/Redo: Built-in history management
- 📦 Lightweight: Relatively small bundle size compared to other rich text editors
- 📦 AI copilot ready: Support inline text suggestions for ai completions.
- YJS backed editing
- Basic block operations and text operations.
- Stable data structure
- Undo/Redo
- Rich text formatting
- Customizable with snippets
- Plugin system
- Text suggestions
- Inline blocks
- Nesting
- Selection + movable blocks
- Content transformation
- Content normalization
- Island blocks
- Customizable hotkeys
- Void elements and editable void elements
- Text spanning deletion
- Block spanning deletion.
- Readable JSON data structure
- Readonly edytor to lightweightly render static content without the Y.js extra works.
- Children normalization
- DND
- Battle tested collaborative editing + awareness + providers
- Block suggestions
- Reactive data (inline)block properties with syncrostate.
Edytor structure is built around this key concepts:
- Blocks: Container elements like paragraphs, headings, and lists
- Content: The content of a block is an array of inlines blocks or text and marks.
- Children: Children are the blocks that are directly inside a block. They allow an infinite nesting.
- Marks: Texts are simply text with marks that define the formatting.
- Inline blocks: Inline blocks are inline elements that are no editable and render custom components like footnotes, equations, etc.
Schematic example of a document: (content and children are not dom element, i put them here to help you understand the structure)
<root>
<block>
<content>
<text mark="bold">Hello</text>
<text>World</text>
<inline-block type="footnote">
<!-- Inline block are rendered by the user code -->
</inline-block>
</content>
<children>
<nested-block>
<content>
<text>World</text>
</content>
</nested-block>
<nested-block>
<content>
<text>World</text>
</content>
<children>
<nested-block>
<content>
<text>World</text>
</content>
</nested-block>
</children>
</nested-block>
</children>
</block>
</root>
Blocks are the container elements like paragraphs, headings, and lists. They have a content that is an array of inlines blocks or text. They may have children that is an array of nested-blocks. Blocks can be nested unless they are void or inside an island
An island is a block that is editable but is structuraly stable and isolated from the rest of the document. It is impossible to merge an island with another block. It is also impossible to move another block inside an island. You may think of an island as a block that is editable but is not completely part of the document structure and isolated from the rest of the document.
A void block is a block which does not have children or whose children are not editable and rendered outside of the edytor core logic. Void blocks can render and edit their content anyway. That is usefull to render caption. You may think of a void block as a block that is completely independent from the rest of the document. Void blocks acts also like an island but are even less editables.
Text is the basic text element that is rendered by the editor. At is core it is a Y.js text with any formatting attributes you want.
Inlines are inline blocks, useful to render custom components like footnotes, equations, etc. They are rendered by the user code and are not editable nor focusable. They have a data property
npm install edytor
# or
yarn add edytor
# or
pnpm add edytor
<script>
import { Edytor } from 'edytor';
let value = {
children: [
{
type: 'paragraph',
content: [{ text: 'Hello, World!' }]
}
]
};
function onChange(newValue) {
console.log('Document changed:', newValue);
}
</script>
<Edytor {value} {onChange} />
Plugins are the primary way to extend Edytor's functionality. They allow you to add custom blocks, marks, inline blocks, hotkeys, and hook into various editor events. Each plugin is a function that receives the editor instance and returns a set of definitions and operations.
Plugins are best written in Svelte files (.svelte
) to take full advantage of Svelte's snippets system and template syntax when defining block and mark snippets. You can define snippets for blocks, marks, inline blocks, hotkeys, and operations and still be able to use them inside the <script module>
</script>
tag of your file that will export the whole plugin.
A basic plugin structure looks like this:
const MyPlugin = (editor: Edytor) => ({
// Define custom blocks
blocks: {
myBlock: {
snippet: /* Svelte snippet */,
// ... block options
}
},
// Define custom marks
marks: {
myMark: {
snippet: /* Svelte snippet */,
// ... mark options
}
},
// Define custom inline blocks
inlineBlocks: {
myInline: {
snippet: /* Svelte snippet */,
// ... inline block options
}
},
// Define custom hotkeys
hotkeys: {
'mod+b': (e) => {
// Handle hotkey
}
},
// Define plugin operations
onBeforeOperation: (payload) => {
// Handle before operation
},
// ... other operations
});
Blocks are the fundamental building blocks of the editor. They can be paragraphs, headings, lists, or any custom block type.
Option | Type | Description | Example Use Case |
---|---|---|---|
snippet |
Snippet |
Svelte snippet for rendering the block | Defining how a code block renders with syntax highlighting |
void |
boolean |
If true, block is not editable but can have editable captions | Image blocks with editable captions |
island |
boolean |
If true, block is editable but structurally isolated | Code blocks that should be merged with other blocks |
transformText |
Function |
Transform text content within the block | Adding syntax highlighting to code blocks in real-time |
onFocus |
Function |
Called when block receives focus | Showing a toolbar when focusing a heading block |
onBlur |
Function |
Called when block loses focus | Make an indicator disapear |
onSelect |
Function |
Called when block is selected | Showing resize handles when selecting an image block |
onDeselect |
Function |
Called when block is deselected | Hiding UI controls when deselecting a block |
normalizeContent |
Function |
Normalize block content after operations | Ensuring list items always start with a bullet point |
normalizeChildren |
Function |
Normalize block children after operations | Ensuring table cells are properly structured |
schema |
any |
Schema for synchronization state data | Defining the structure of a table block's metadata |
Marks are used for text formatting like bold, italic, or custom formatting.
Option | Type | Description | Example Use Case |
---|---|---|---|
snippet |
Snippet |
Svelte snippet for rendering the mark | Rendering highlighted text with a custom background color |
Operations allow you to hook into various editor events and modify behavior.
Operation | Description | Example Use Case |
---|---|---|
onBeforeOperation |
Called before any operation is executed | Validating table cell merges before they happen |
onAfterOperation |
Called after any operation is executed | Updating a table of contents after heading changes |
onChange |
Called when editor value changes | Syncing content with external storage |
onSelectionChange |
Called when selection changes | Updating a formatting toolbar position |
placeholder |
Define placeholder content for empty blocks | Showing "Type '/' for commands" in empty blocks |
onEdytorAttached |
Called when editor is attached to DOM | Initializing third-party libraries |
onBlockAttached |
Called when a block is attached to DOM | Running some svelte action on the node |
onTextAttached |
Called when text is attached to DOM | Running some svelte action on the node |
defaultBlock |
Define default block type when a new one is inserted | Using different default blocks based on context |
onDeleteSelectedBlocks |
Called when selected blocks are deleted | Cleaning up resources when deleting media blocks |
onBeforeInput |
Called before input is processed | Converting markdown shortcuts as you type |
Many plugin operations and hotkeys receive a prevent
function as part of their payload. This function is a crucial part of Edytor's plugin system that allows you to:
- Stop the default behavior of an operation
- Register a callback to be executed after the default operation is aborted
- Control the flow of operations across multiple plugins
Here's how it works:
// In a hotkey handler precent will also doest a preventDefault on the keyboard event.
hotkeys: {
'mod+b': ({ prevent }) => {
// Prevent default and do nothing
prevent();
// Or register a callback to be executed after the operation is aborted
prevent(() => {
// This code runs after the default operation is aborted
// Use this to implement your custom behavior
});
}
}
// In an operation handler
onBeforeOperation: ({ prevent, operation, payload }) => {
if (operation === 'splitBlock') {
prevent(() => {
// This callback will be executed after the default split operation is aborted
// Implement your custom split logic here
});
}
}
When using multiple plugins, prevention follows these rules:
- If a plugin prevents an operation without providing a callback, the operation is completely stopped
- If a plugin prevents an operation with a callback, the default operation is aborted and then the callback is executed
- If multiple plugins try to prevent the same operation, only the first prevention (in plugin order) takes effect
- If a plugin doesn't call prevent(), the operation continues to the next plugin or executes the default behavior
This system allows plugins to:
- Completely stop operations when needed
- Replace default behavior with custom logic
- Ensure their custom logic runs only after the default behavior is properly aborted
- Build complex features while maintaining predictable behavior
<script module>
export const boldPlugin = (editor: Edytor) => ({
marks: {
bold: {
snippet: bold
}
},
hotkeys: {
'mod+b': ({prevent}) => {
prevent(()=>{
// Do something
});
}
}
});
</script>
{#snippet bold({content}: MarkSnippetPayload)}
<strong>{@render content()}</strong>
{/snippet}
To use plugins, pass them to the Edytor component. The order of the plugins is important because the plugins are executed in the order they are passed. So if two plugins are trying to render the same block, the first plugin will win. If two pluggins defined the same hotkey and prevent it, the second plugin will not be executed.
<script>
import { Edytor } from 'edytor';
import { BoldPlugin, HeadingPlugin } from './plugins';
const plugins = [BoldPlugin, HeadingPlugin];
</script>
<Edytor {plugins} />
I'm welcome to any contribution to improve the testing. In the end, every block operation should be tested. I've implemented a custom jsx parser to simplify testing the editor.
So instead of defining the value as a json object, you can define the value as a jsx element.
<root>
<paragraph>Hello, World!</paragraph>
</root>
is the same as
{
"type": "root",
"children": [{ "type": "paragraph", "content": [{ "text": "Hello, World!" }] }]
}
You can also add one or two cursors with the |
character into the jsx in order to simulate the cursor position
<root>
<paragraph>Hello, |World!|</paragraph>
</root>
I've also implemented the createTestEdytor
that help with creating an edytor instance from a jsx element in order to test various operations on a virtual edytor and test the expected output.
test('split text', () => {
const { edytor, expect } = createTestEdytor(
<root>
<paragraph>Hello, |World!</paragraph>
</root>
);
edytor.selection.state.startBlock?.splitBlock({
index: edytor.selection.state.yStart,
text: edytor.selection.state.startText
});
expect(
<root>
<paragraph>Hello, </paragraph>
<paragraph>World!</paragraph>
</root>
);
});