Build Editable Svelte Websites with JSON Content – Svedit

Build custom content editors in Svelte with Svedit. JSON-based modeling, transaction updates, and direct editing in your layout.

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 dev

2. 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.

Michael Aufreiter

Michael Aufreiter

Leave a Reply

Your email address will not be published. Required fields are marked *