svedit Documentation

repository·main·Indexed 18 days ago

https://github.com/michael/svedit

A structured, full-canvas content editing library for Svelte (v0.13.0) that allows developers to model content as JSON and render it with custom Svelte components. It features a schema-driven approach with support for text and block nodes, marks, annotations, and an atomic transaction system for document operations, undo/redo history, and native browser selection integration.

Tokens
16K
Snippets
53
Records
71
Agent score
70%

What's inside svedit

  1. Understand Svedit selection types

    main

    Svedit uses three distinct selection types to define what is currently active in the editor:

    1. Text Selection: Spans a range of characters within a string. Uses anchor_offset and focus_offset to define the range.
    2. Node Selection: Spans a range of nodes within a node_array. The path points to the array, and offsets indicate the node indices.
    3. Property Selection: Targets a specific property of a single node (e.g., an image property).

    Access the current selection via session.selection and set it programmatically using session.selection = new_selection.

    // Text Selection Example
    {
      type: 'text',
      path: ['page_1234', 'body', 0, 'content'],
      anchor_offset: 1,
      focus_offset: 1
    }
    
    // Node Selection Example
    {
      type: 'node',
      path: ['page_1234', 'body'],
      anchor_offset: 2,
      focus_offset: 4
    }
    
    // Property Selection Example
    {
      type: 'property',
      path: ['page_1', 'body', 11, 'image']
    }
  2. Avoid duplicate node path mounting

    main

    Within a single Svedit document, a specific node path must be mounted exactly once.

    Consequences of duplicate mounting:

    • Breaks anchor positioning.
    • Breaks intersection tracking.
    • Breaks ID uniqueness.
    • Breaks selection mapping.

    Svedit will log an error if it detects a duplicate mount. If you need to show the same content in two places (e.g., a header and a footer), you should either model them as distinct node arrays in the schema (e.g., header_nav and footer_nav) or use separate Svedit instances.

  3. Understand the Svedit Document structure

    main

    A Svedit document is a Plain Old JavaScript Object (POJO) consisting of a document_id (the entry point) and a nodes object containing all content nodes.

    Core Rules:

    • Reachability: All nodes must be reachable from the document node; unreachable nodes are discarded.
    • No Cycles: Cyclic references are not allowed.
    • Text Properties: Must use the shape { content: '', marks: [], annotations: [] }.
    • Node Array Properties: Must use the shape { nodes: [], marks: [], annotations: [] }.
    • ID Mapping: The key in the nodes map must exactly match the node's own id property.
    const doc = {
    	document_id: 'page_1',
    	nodes: {
    		page_1: {
    			id: 'page_1',
    			type: 'page',
    			body: {
    				nodes: ['paragraph_1'],
    				marks: [],
    				annotations: []
    			}
    		},
    		paragraph_1: {
    			id: 'paragraph_1',
    			type: 'paragraph',
    			content: {
    				content: 'Hello world.',
    				marks: [],
    				annotations: []
    			}
    		}
    	}
    };
  4. How app-level commands and scope hierarchy work

    main

    Svedit manages commands using a scope stack to handle different levels of application state:

    1. App-level scope: Top-level commands that are always available (e.g., SaveCommand, ToggleEditModeCommand). They operate on an application-wide context.
    2. Document-level scope: Commands bound to a specific Svedit instance (e.g., UndoCommand).

    Scope Management

    When a Svedit instance gains focus, its document-level scope is pushed onto the stack. When it loses focus, the scope is popped. This ensures that commands automatically target the currently focused document.

    // Example App-level command
    class SaveCommand extends Command {
    	is_enabled() {
    		return this.context.editable;
    	}
    
    	async execute() {
    		await this.context.save_all_documents();
    		this.context.show_notification('All changes saved');
    	}
    }
  5. Handle marks and annotations in NodeArrayProperty

    main

    When using <NodeArrayProperty>, child node components can access range context via props.

    • mark: Represents the single mark wrapping the child node (or null). Mark exclusivity ensures at most one mark is active.
    • annotations: An array of all annotations covering the child node. Unlike marks, annotations can overlap.

    Each annotation entry contains: start_offset, end_offset, node_id, index, node (the resolved node), and boolean flags is_start, is_middle, and is_end describing the child's position within the range.

    Styling Tip: For simple visual changes, use CSS targeting the automatic classes generated by <Node> (e.g., .mark-section or .anno-marker). Use the annotations prop only when you need to render complex UI like badges.

    <script>
    	let { path, mark: section = null, annotations = [] } = $props();
    </script>
    
    <div class:section-start={section?.is_start}>
    	<!-- render node content -->
    </div
  6. Use Transforms to encapsulate editing logic

    main

    Transforms are pure functions that take a transaction (tr) and modify it. They return true if the operation was successful and false if it could not be applied (e.g., due to invalid state). They are highly composable.

    import { break_text_node } from 'svedit';
    
    const tr = session.tr;
    const success = break_text_node(tr);
    if (success) {
    	session.apply(tr);
    }
    
    // Composing transforms
    function custom_transform(tr) {
    	if (!break_text_node(tr)) return false;
    	if (!insert_default_node(tr)) return false;
    	return true;
    }
  7. Use Marks and Annotations for content styling and metadata

    main

    Both Marks and Annotations are attached to a range in a property value using the following shape:

    {
    	start_offset: 0,
    	end_offset: 5,
    	node_id: 'target_node_id'
    }

    Key Differences:

    • Marks: Part of the content (e.g., bold, italic, link). They are mutually exclusive within a property and are rendered in-place. Offsets refer to character positions in text properties or node positions in node_array properties.
    • Annotations: Metadata layered over content (e.g., comments, markers). They can overlap each other and marks. They are data-only; Svedit manages the data and CSS classes, but your application must interpret the data.

    Note: Ranges are half-open: start_offset is included, end_offset is excluded.

    // Example: Text property with both a mark and an annotation
    {
    	id: 'paragraph_1',
    	type: 'paragraph',
    	content: {
    		content: 'Hello world.',
    		marks: [{ start_offset: 0, end_offset: 5, node_id: 'strong_1' }],
    		annotations: [{ start_offset: 3, end_offset: 8, node_id: 'comment_1' }]
    	}
    }
  8. Choosing between `text` and `block` node kinds

    main

    When defining your schema, choosing the correct kind for a node is critical for how the editor handles splitting and joining text.

    kind: 'text'

    Use this for nodes where the primary purpose is editable text. This opts into the automatic split/join system (break_text_node, join_text_node). Requirements for text kind:

    • The node must have exactly one text property named content.
    • Pressing Enter must logically split the node into two nodes of the same type.
    • Pressing Backspace at position 0 must logically join it with the previous node.

    kind: 'block'

    Use this if any of the text assumptions fail. Blocks can still contain editable text properties, but they do not participate in the automatic split/join logic.

    Common Mistake: A node like a quote that has both content and author properties should be a block. If you mark it as text, the join_text_node transform (which hard-codes node.content) will fail to handle the author property correctly during a Backspace operation.

  9. How Svedit works

    main

    Svedit is a structured content editor that connects several key abstractions to turn a JSON model into an editable UI:

    1. Schema: Defines the structure (node types, properties, marks, annotations).
    2. Document: The data model containing a document_id and a flat map of nodes.
    3. Session: Manages the document, current selection state, and history.
    4. Transaction: An atomic unit of document operations (create, delete, set) supporting undo/redo.
    5. Transforms: Composable functions (e.g., break_text_node) that run inside transactions to modify the document.
    6. Config: Maps node types to Svelte components and provides inserters/commands.
    7. Components: Svelte components used to render each node type.
    8. Commands: User actions (e.g., bold, undo) that trigger transactions via the session.

    The Lifecycle: Define Schema $\rightarrow$ Create Session $\rightarrow$ Provide Config $\rightarrow$ Render with <Svedit> component. User interactions trigger commands, which create transactions, which run transforms to modify the document, which the session applies, finally triggering Svelte reactivity to update the UI.

  10. Migrate documents using fill_document_defaults

    main

    Svedit validates documents against the schema when a Session is created. If you change your schema (e.g., adding new properties), you must migrate existing documents.

    For simple additive changes, use fill_document_defaults to populate missing properties with their schema-defined default values before creating the session. This function does not mutate the original document.

    Warning: fill_document_defaults is not a replacement for real migrations. If you rename properties, remove properties, or change node types, you must write a custom migration script.

    import { Session, fill_document_defaults } from 'svedit';
    
    const document_schema = {
    	paragraph: {
    		kind: 'text',
    		properties: {
    			layout: {
    				type: 'string',
    				values: ['image-left', 'image-right', 'stacked'],
    				default: 'image-left'
    			},
    			content: {
    				type: 'text',
    				allow_newlines: true
    			}
    		}
    	}
    };
    
    // Use helper to fill missing properties before session creation
    const migrated_doc = fill_document_defaults(existing_doc, document_schema);
    const session = new Session(document_schema, migrated_doc, config);
  11. Render an editable Svedit component

    main

    To make a Svelte page editable, wrap your design inside the <Svedit> component and provide a session and a path.

    <Svedit {session} path={[session.doc.document_id]} editable={true} />
  12. Create Node Components in Svelte

    main

    Node components are Svelte components responsible for rendering specific node types. Every node component must follow a specific pattern: it receives a path prop and must wrap its content in the <Node> component. This registration allows the editor to handle selection, caret behavior, and interaction for that node.

    Basic Pattern:

    1. Import Node and the appropriate property component (e.g., TextProperty, NodeArrayProperty).
    2. Accept path via $props().
    3. Wrap the rendering logic in <Node {path}>.
    <script>
    	import { Node, TextProperty } from 'svedit';
    	let { path } = $props();
    </script>
    
    <Node {path}>
    	<div class="my-node">
    		<TextProperty path={[...path, 'content']} />
    	</div>
    </Node>