Svedit is a Svelte library that turns Svelte websites into in-place editable content systems.
You model content with JSON, render it through custom Svelte components, and allows users to edit content and structure directly within the layout.
Features
đ JSON-based content modeling: Define document structure with a schema that enforces node types, properties, and relationships.
⥠Svelte reactivity: Content updates trigger automatic UI refreshes through Svelte’s reactive system with no manual DOM manipulation.
đ¯ In-place editing: You can edit content directly in the layout with no context switching to admin panels.
âŠī¸ Transaction-based updates: Group multiple operations into atomic units with built-in undo and redo support.
đ¨ Chromeless canvas: Keep the editing UI clean with toolbars and controls in separate overlays.
â¨ī¸ Keyboard shortcuts: Manage keybindings through a stack system that activates commands based on focus context.
đ§ White-box: Access and customize library internals, including toolbar behavior, overlay rendering, and cursor display.
đ Schema validation: Enforce content constraints at the document level to maintain data integrity.
đ§Š Custom transforms: Write composable functions that modify documents through the transaction API.
đ Bidirectional selection sync: DOM selections map to internal paths and back automatically.
Use Cases
- Documentation Sites: Build technical docs where contributors edit pages directly in the browser.
- Landing Pages: Create marketing pages with strictly defined blocks that non-technical users can update.
- Lightweight Blogs: Manage blog posts with custom rich text components and media blocks.
- Custom Form Builders: Design interfaces where administrators construct forms by dragging and dropping components.
How to Use It
1. Clone the starter repository and install dependencies:
git clone https://github.com/michael/hello-svedit
cd hello-svedit
npm install
npm run dev2. Define your content schema with node types and properties. Each node needs a kind that determines its behavior:
const document_schema = {
page: {
kind: 'document',
properties: {
body: {
type: 'node_array',
node_types: ['paragraph', 'list', 'heading'],
default_node_type: 'paragraph'
}
}
},
paragraph: {
kind: 'text',
properties: {
content: {
type: 'annotated_text',
allow_newlines: true
}
}
},
heading: {
kind: 'text',
properties: {
content: {
type: 'annotated_text',
allow_newlines: false
},
level: {
type: 'integer'
}
}
},
list: {
kind: 'block',
properties: {
items: {
type: 'node_array',
node_types: ['list_item'],
default_node_type: 'list_item'
}
}
},
list_item: {
kind: 'text',
properties: {
content: {
type: 'annotated_text',
allow_newlines: false
}
}
}
};3. Create a document that matches your schema structure:
const initial_document = {
document_id: 'page_1',
nodes: {
paragraph_1: {
id: 'paragraph_1',
type: 'paragraph',
content: {
text: 'Welcome to the page.',
annotations: []
}
},
list_item_1: {
id: 'list_item_1',
type: 'list_item',
content: {
text: 'First item',
annotations: []
}
},
list_item_2: {
id: 'list_item_2',
type: 'list_item',
content: {
text: 'Second item',
annotations: []
}
},
list_1: {
id: 'list_1',
type: 'list',
items: ['list_item_1', 'list_item_2']
},
page_1: {
id: 'page_1',
type: 'page',
body: ['paragraph_1', 'list_1']
}
}
};4. Create Svelte components for each node type. Each component wraps content in the Node component and uses property components:
<script>
import { Node, AnnotatedTextProperty } from 'svedit';
import { getContext } from 'svelte';
const svedit = getContext('svedit');
let { path } = $props();
let node = $derived(svedit.session.get(path));
</script>
<Node {path}>
<p class="paragraph">
<AnnotatedTextProperty
path={[...path, 'content']}
placeholder="Enter text"
/>
</p>
</Node>5. Create a configuration object that maps node types to components and defines commands:
import {
UndoCommand,
RedoCommand,
ToggleAnnotationCommand,
BreakTextNodeCommand,
define_keymap
} from 'svedit';
import { nanoid } from 'nanoid';
const session_config = {
generate_id: () => nanoid(),
node_components: {
Page,
Paragraph,
Heading,
List,
ListItem
},
system_components: {
NodeCursorTrap,
Overlays
},
inserters: {
paragraph: (tr) => {
const id = nanoid();
tr.create({
id,
type: 'paragraph',
content: { text: '', annotations: [] }
});
tr.insert_nodes([id]);
}
},
create_commands_and_keymap: (context) => {
const commands = {
undo: new UndoCommand(context),
redo: new RedoCommand(context),
toggle_bold: new ToggleAnnotationCommand('strong', context),
toggle_italic: new ToggleAnnotationCommand('emphasis', context),
break_node: new BreakTextNodeCommand(context)
};
const keymap = define_keymap({
'meta+z,ctrl+z': [commands.undo],
'meta+shift+z,ctrl+shift+z': [commands.redo],
'meta+b,ctrl+b': [commands.toggle_bold],
'meta+i,ctrl+i': [commands.toggle_italic],
'enter': [commands.break_node]
});
return { commands, keymap };
}
};6. Initialize a session and render the editor in your Svelte page:
<script>
import { Session, Svedit } from 'svedit';
import { session_config } from './config.js';
let editable = $state(true);
const session = new Session(
document_schema,
initial_document,
session_config
);
</script>
<Svedit
{session}
path={[session.doc.document_id]}
{editable}
/>7. Handle document changes by comparing references since the document uses immutable updates:
let last_saved_doc = $state(null);
let has_changes = $derived.by(() => {
if (!last_saved_doc) {
return session.can_undo;
}
return last_saved_doc !== session.doc;
});
async function save() {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(session.doc)
});
last_saved_doc = session.doc;
}8. Add custom commands by extending the Command class:
import { Command } from 'svedit';
class InsertHeadingCommand extends Command {
is_enabled() {
return this.context.editable &&
this.context.session.selection?.type === 'node';
}
execute() {
const tr = this.context.session.tr;
const id = tr.generate_id();
tr.create({
id,
type: 'heading',
level: 2,
content: { text: '', annotations: [] }
});
tr.insert_nodes([id]);
this.context.session.apply(tr);
}
}9. Create custom transforms for reusable document operations:
function convert_to_list(tr) {
const selection = tr.selection;
if (selection?.type !== 'node') return false;
const selected_nodes = tr.get(selection.path)
.slice(selection.anchor_offset, selection.focus_offset);
const list_id = tr.generate_id();
const item_ids = selected_nodes.map(node => {
const item_id = tr.generate_id();
tr.create({
id: item_id,
type: 'list_item',
content: node.content
});
return item_id;
});
tr.create({
id: list_id,
type: 'list',
items: item_ids
});
tr.delete_selection();
tr.insert_nodes([list_id]);
return true;
}API Reference
Session Class
new Session(schema, document, config): Creates a new session instance with the provided schema, initial document, and configuration
session.get(path): Retrieves a node or property value at the specified path
session.inspect(path): Returns metadata about a property including its kind, type, and allowed node types
session.kind(node): Returns the kind of a node (text, block, document, or annotation)
session.selection: Gets or sets the current selection object
session.selected_node: Derived property returning the currently selected node
session.active_annotation(type): Checks if an annotation type is active at the current cursor position
session.can_insert(node_type): Checks if a node type can be inserted at the current selection
session.available_annotation_types: Derived property returning annotation types allowed at current selection
session.tr: Creates a new transaction object
session.apply(transaction): Applies a transaction to update the document
session.undo(): Reverts the last transaction
session.redo(): Reapplies the last undone transaction
session.can_undo: Derived boolean indicating if undo is available
session.can_redo: Derived boolean indicating if redo is available
session.validate_doc(): Validates all nodes against the schema
session.traverse(node_id): Returns all nodes reachable from a given node
session.select_parent(): Selects the parent of the current selection
session.generate_id(): Generates a new unique identifier
session.config: Access the configuration object
session.doc: The current immutable document state
session.doc.document_id: The root node identifier
Transaction Class
tr.get(path): Reads a value from the transaction state
tr.inspect(path): Returns property metadata
tr.kind(node): Returns node kind
tr.generate_id(): Generates a unique identifier
tr.create(node): Creates a new node with all required properties
tr.delete(node_id): Removes a node and unreferenced children
tr.set(path, value): Updates a property value
tr.insert_nodes(path, offset, node_ids): Inserts nodes into a node array
tr.build(root_id, node_map): Creates a subgraph from a map of node definitions
tr.insert_text(text): Inserts text at the cursor position
tr.annotate_text(type, attributes?): Toggles an annotation on selected text
tr.delete_selection(): Removes the currently selected content
tr.set_selection(selection): Updates the selection state
Schema Definition
kind: Node behavior type (document, block, text, or annotation)
properties: Object defining node properties and their types
type: Property value type (string, number, integer, boolean, annotated_text, node, node_array, string_array, integer_array, number_array)
node_types: Array of allowed node types for node and node_array properties
default_node_type: Default type when inserting new nodes
allow_newlines: Boolean for annotated_text properties controlling newline behavior
Configuration Object
generate_id: Function returning unique identifiers for new nodes
node_components: Map of node types to Svelte components
system_components: Object with NodeCursorTrap and Overlays components
inserters: Map of functions that create and insert new nodes of each type
create_commands_and_keymap: Factory function returning commands and keymap for an editor instance
handle_image_paste: Optional function handling image paste events
Command Class
is_enabled(): Method returning boolean indicating if command can execute
execute(): Method performing the command action (sync or async)
disabled: Derived property automatically computed from is_enabled
context: Object containing session, editable state, canvas element, and composition state
KeyMapper Class
new KeyMapper(): Creates a new keyboard shortcut manager
push_scope(keymap): Adds a keymap to the top of the scope stack
pop_scope(): Removes the most recent keymap from the stack
handle_keydown(event): Processes keyboard events and executes matching commands
define_keymap(map): Helper function creating a keymap from key-command pairs
Property Components
<AnnotatedTextProperty>: Renders editable text with inline formatting support
path: Array specifying the property location
tag: HTML element type (defaults to div)
class: CSS class name
placeholder: Text shown when empty
<NodeArrayProperty>: Renders a container of child nodes
path: Array specifying the property location
class: CSS class name
<CustomProperty>: Wraps non-text content like images
path: Array specifying the property location
class: CSS class name
<Node>: Wrapper component required for all node components
path: Array specifying the node location
Built-in Transforms
break_text_node(tr): Splits a text node at cursor position
join_text_node(tr): Merges current text node with previous one
insert_default_node(tr): Inserts a new node at current selection
Built-in Commands
UndoCommand: Reverts the last change
RedoCommand: Reapplies the last undone change
SelectParentCommand: Selects the parent of current selection
ToggleAnnotationCommand: Toggles text formatting annotations
AddNewLineCommand: Inserts newline character in text
BreakTextNodeCommand: Splits text node at cursor
SelectAllCommand: Progressively expands selection
InsertDefaultNodeCommand: Inserts new node at cursor
Selection Types
Text Selection: Spans character range in a string property with anchor_offset and focus_offset
Node Selection: Spans node range in a node_array property with anchor_offset and focus_offset
Property Selection: Addresses a single property of a node
Related Resources
- ProseMirror: A toolkit for building rich text editors with a similar document model and transaction system
- Tiptap: A headless editor framework built on ProseMirror with ready-made extensions
- Lexical: Facebook’s extensible text editor framework with a plugin architecture
- Slate: A customizable framework for building rich text editors in React
FAQs
Q: Can Svedit work with existing Svelte applications?
A: Yes. You integrate Svedit by wrapping your content components in the Svedit component and defining a schema that matches your data structure.
Q: How does Svedit handle concurrent edits from multiple users?
A: Svedit implements a last-write-wins approach for concurrent updates. The library does not include built-in operational transformation or conflict resolution, so you need to add those features if your application requires real-time collaboration.
Q: What happens to nodes that become unreachable after a deletion?
A: The transaction system automatically removes unreachable nodes when you delete a parent node. Any node not reachable from the document root gets discarded during transaction application.
Q: Can I use Svedit with server-side rendering?
A: The editing interface requires client-side JavaScript since it depends on contenteditable and DOM selection APIs. You can render content server-side in read-only mode, then hydrate the editor on the client when users need to edit.
Q: How do I persist documents to a database?
A: Documents are plain JavaScript objects that serialize to JSON. You detect unsaved changes by comparing document references, then send the serialized document to your backend API.
Q: Can I use custom Svelte components?
A: Yes. You map every node type in your schema to a specific Svelte component.





