{"id":17718329,"url":"https://github.com/michael/svedit","last_synced_at":"2026-04-01T18:03:12.463Z","repository":{"id":258808399,"uuid":"842230863","full_name":"michael/svedit","owner":"michael","description":"A tiny library for building editable websites in Svelte","archived":false,"fork":false,"pushed_at":"2026-01-23T22:59:17.000Z","size":1879,"stargazers_count":530,"open_issues_count":38,"forks_count":11,"subscribers_count":9,"default_branch":"main","last_synced_at":"2026-01-24T11:18:59.847Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://svedit.dev","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/michael.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-08-13T23:57:26.000Z","updated_at":"2026-01-23T22:59:20.000Z","dependencies_parsed_at":"2025-05-17T16:04:36.413Z","dependency_job_id":"a15c1c7d-2428-404a-95f1-0ba0bdbede8d","html_url":"https://github.com/michael/svedit","commit_stats":null,"previous_names":["michael/svedit"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/michael/svedit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael%2Fsvedit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael%2Fsvedit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael%2Fsvedit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael%2Fsvedit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/michael","download_url":"https://codeload.github.com/michael/svedit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael%2Fsvedit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28731038,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-24T10:24:43.181Z","status":"ssl_error","status_checked_at":"2026-01-24T10:24:36.112Z","response_time":89,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-25T14:44:11.266Z","updated_at":"2026-04-01T18:03:12.445Z","avatar_url":"https://github.com/michael.png","language":"JavaScript","funding_links":[],"categories":["JavaScript"],"sub_categories":[],"readme":"# Svedit\n\nSvedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. You can model your content in JSON, render it with custom Svelte components, and (this is the kicker) site owners can **edit their site directly in the layout** — no CMS needed.\n\n- Try the [Svedit demo](https://svedit.dev) — watch the debug output at the bottom of the page\n- Try [Editable Website](https://editable.website) — utilizes Svedit to enable CMS-free editable websites\n\n## Why Svedit?\n\nBecause Svelte‘s reactivity system is **the perfect fit** for building super-lightweight content editing experiences. In fact, they're so lightweight, **your content is your editor** — no context switching between a backend and the live site. Svedit just gives you the gluing pieces around **defining a custom document model** and **mapping DOM selections** to the internal model and vice versa.\n\n## Getting started\n\nThe fastest way to get started is to clone the `hello-svedit` template and turn it into your own project:\n\n```bash\ngit clone https://github.com/michael/hello-svedit\ncd hello-svedit\nnpm install\nnpm run dev\n```\n\nNow make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/components/Toolbar.svelte), and render custom [Overlays](./src/routes/components/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes).\n\nYou can also install Svedit into an existing SvelteKit project with `npm install svedit`, but you'll need to set up the session, schema, config, and components yourself. See the [hello-svedit repo](https://github.com/michael/hello-svedit) or this repo's [`src/routes`](./src/routes) for reference.\n\n## Principles\n\n**Simplicity over completeness:** Svedit doesn't guess what your app needs or offer ready-made blocks. Instead, we keep the core lean and provide carefully crafted examples showing how to build anything on top — without compromising flexibility.\n\n**White-box library:** We expose the internals of the library to allow you to customize and extend it to your needs. That means a little bit more work upfront, but in return lets you control \"everything\" — the toolbar, the overlays, or how fast the node caret blinks.\n\n**Chromeless canvas:** Svedit keeps the editing canvas chromeless, meaning there are no UI elements like toolbars or menus mingled with the content. You can interact with text directly, but everything else happens via tools shown in separate overlays or in the fixed toolbar.\n\n## How it works\n\nSvedit connects eight key pieces:\n\n1. **Schema** - Define your content structure (node types, properties, annotations)\n2. **Document** - An actual document, containing a `document_id` and a flat map of nodes that hold the content\n3. **Session** - Manages the document, selection state, and history\n4. **Transaction** - Groups multiple document operations (create, delete, set) into a single atomic unit with undo/redo support\n5. **Transforms** - Higher-level composable functions that run inside a transaction (e.g., `break_text_node`, `join_text_node`) to modify the document.\n6. **Config** - Maps node types to components, provides inserters and commands\n7. **Components** - Render your content using Svelte (one component per node type)\n8. **Commands** - User actions (bold text, insert node, undo/redo) that modify the session\n\n**The flow:**\n- Define a schema → create a session → provide config → render with `\u003cSvedit\u003e` component\n- User interactions trigger commands → commands create transactions → which run transforms to modify the document → session applies the transaction → Svelte's reactivity updates the UI\n- Selection state syncs bidirectionally with the DOM\n\n## Schema\n\nYou can use a simple JSON-compatible schema definition language to enforce constraints on your documents. E.g. to make sure a page node always has a property body with references to nodes that are allowed within a page.\n\nFirst off, everything is a node. The page is a node, and so is a paragraph, a list, a list item, a nav and a nav item.\n\nEach node has a `kind` that determines its behavior:\n- `document`: A top-level node accessible via a route (e.g. a page, event)\n- `block`: A structured node that contains other nodes or properties\n- `text`: A node with editable text content (can be split and joined)\n- `annotation`: An inline annotation applied to text (bold, link, etc.)\n\n### Choosing between `text` and `block`\n\n`kind: 'text'` opts into the split/join system (`break_text_node`, `join_text_node`), which assumes:\n- The node has exactly **one** `annotated_text` property named **`content`**\n- Pressing Enter splits the node into two nodes of the same type\n- Pressing Backspace at position 0 joins it with the previous node\n\nIf **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can still have `annotated_text` properties with full editing support (typing, formatting, selection) — they just don't participate in split/join.\n\n\u003e **Common mistake:** A quote node with `content` + `author` properties might seem like `kind: 'text'` because both fields are editable text. But splitting a quote into two half-quotes doesn't make sense, and `join_text_node` hard-codes `node.content` — so Backspace in the `author` field would join the wrong property with the previous block and drop the author text. The correct kind is `'block'`.\n\n\u003cdetails\u003e\n\u003csummary\u003eDecision tree for choosing a node kind\u003c/summary\u003e\n\n```mermaid\nflowchart TD\n    Start([\"I need a new node type\"])\n    Q1{\"Is it inline formatting applied within text?\"}\n    Q2{\"Is it a top-level routable entry point?\"}\n    Q3{\"Is text the node's entire purpose?\"}\n    Q4{\"Does it have exactly one annotated_text property?\"}\n    Q5{\"Does pressing Enter to split into two nodes make sense?\"}\n\n    KindAnnotation[\"kind: annotation e.g. bold, italic, link\"]\n    KindDocument[\"kind: document e.g. page, article, event\"]\n    KindBlock[\"kind: block e.g. image+caption, quote+author, list, nav, embed\"]\n    KindText[\"kind: text e.g. paragraph, heading, list_item\"]\n\n    Start --\u003e Q1\n    Q1 --\u003e|Yes| KindAnnotation\n    Q1 --\u003e|No| Q2\n    Q2 --\u003e|Yes| KindDocument\n    Q2 --\u003e|No| Q3\n    Q3 --\u003e|\"No — it has non-text properties or children\"| KindBlock\n    Q3 --\u003e|Yes| Q4\n    Q4 --\u003e|\"No — multiple text properties\"| KindBlock\n    Q4 --\u003e|Yes| Q5\n    Q5 --\u003e|\"No — splitting doesn't make sense\"| KindBlock\n    Q5 --\u003e|Yes| KindText\n```\n\n\u003c/details\u003e\n\nProperties of nodes can hold values:\n- `string`: A good old JavaScript string\n- `number`: Just like a number in JavaScript\n- `integer`: A number for which Number.isInteger(number) returns true\n- `boolean`: true or false\n- `datetime`: A date/time string parseable by `Date.parse()`\n- `string_array`: An array of strings\n- `number_array`: An array of numbers\n- `integer_array`: An array of integers\n- `boolean_array`: An array of booleans\n- `annotated_text`: A plain text string with annotations (bold, italic, link etc.). Set `allow_newlines: true` to let users insert line breaks with Shift+Enter, or `false` to keep content single-line (e.g. for titles).\n\nOr references:\n- `node`: References a single node (e.g. an image node can reference a global asset node)\n- `node_array`: References a sequence of nodes (e.g. page.body references paragraph and list nodes)\n\n\n```js\nconst document_schema = {\n  page: {\n    kind: 'document',\n    properties: {\n      body: {\n        type: 'node_array',\n        node_types: ['nav', 'paragraph', 'list'],\n        default_node_type: 'paragraph',\n      }\n    }\n  },\n  paragraph: {\n    kind: 'text',\n    properties: {\n      content: {\n        type: 'annotated_text',\n        node_types: ['strong', 'emphasis', 'link'],\n        allow_newlines: true\n      }\n    }\n  },\n  list_item: {\n    kind: 'text',\n    properties: {\n      content: {\n        type: 'annotated_text',\n        node_types: ['strong', 'emphasis', 'link'],\n        allow_newlines: true\n      },\n    }\n  },\n  list: {\n    kind: 'block',\n    properties: {\n      list_items: {\n        type: 'node_array',\n        node_types: ['list_item'],\n        default_node_type: 'list_item',\n      }\n    }\n  },\n  nav: {\n    kind: 'block',\n    properties: {\n      nav_items: {\n        type: 'node_array',\n        node_types: ['nav_item'],\n        default_node_type: 'nav_item',\n      }\n    }\n  },\n  nav_item: {\n    kind: 'block',\n    properties: {\n      url: { type: 'string' },\n      label: { type: 'string' },\n    }\n  },\n  strong: {\n    kind: 'annotation',\n    properties: {}\n  },\n  emphasis: {\n    kind: 'annotation',\n    properties: {}\n  },\n  link: {\n    kind: 'annotation',\n    properties: {\n      href: { type: 'string' }\n    }\n  }\n};\n```\n\nAnnotation types are defined as nodes with `kind: 'annotation'`. Simple annotations like `strong` and `emphasis` have no properties, while annotations like `link` can carry data (e.g. `href`). Each `annotated_text` property specifies which annotation types are allowed via `node_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles).\n\n## Document\n\nA document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes.\n\nRules:\n- All nodes must be reachable from the document node (unreachable nodes are discarded)\n- No cyclic references allowed\n- Text content uses `{ text: '', annotations: [] }` format\n\nHere's an example document:\n\n```js\nconst doc = {\n  document_id: 'page_1',\n  nodes: {\n    nav_item_1: {\n      id: 'nav_item_1',\n      type: 'nav_item',\n      url: '/homepage',\n      label: 'Home'\n    },\n    nav_1: {\n      id: 'nav_1',\n      type: 'nav',\n      nav_items: ['nav_item_1']\n    },\n    paragraph_1: {\n      id: 'paragraph_1',\n      type: 'paragraph',\n      content: { text: 'Hello world.', annotations: [] }\n    },\n    list_item_1: {\n      id: 'list_item_1',\n      type: 'list_item',\n      content: { text: 'First list item', annotations: [] }\n    },\n    list_item_2: {\n      id: 'list_item_2',\n      type: 'list_item',\n      content: { text: 'Second list item', annotations: [] }\n    },\n    list_1: {\n      id: 'list_1',\n      type: 'list',\n      list_items: ['list_item_1', 'list_item_2']\n    },\n    page_1: {\n      id: 'page_1',\n      type: 'page',\n      body: ['nav_1', 'paragraph_1', 'list_1']\n    }\n  }\n};\n```\n\n## Config\n\nDocuments need a config object that tells Svedit how to render and manipulate your content. See the full example in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).\n\nTwo optional hooks are especially useful when integrating custom media workflows:\n\n- `handle_media_paste(session, pasted_media)`  \n  Called when media is pasted. You can upload/process files, replace an existing media property, or return a node payload to insert new content.  \n  See example implementation in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).\n\n- `handle_property_deletion(session, path)`  \n  Called when deleting/cutting a property selection. Use it to define app-specific reset behavior (for example clearing an image property or resetting a referenced media node).  \n  See example implementation in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).\n\n```js\nconst session_config = {\n  // ID generator for creating new nodes\n  generate_id: () =\u003e nanoid(),\n  \n  // User-land overlays and optional system component overrides\n  system_components: { Overlays },\n  \n  // Map node types to Svelte components\n  node_components: { Page, Text, Story, List, Button, ... },\n  \n  // Functions that create and insert new nodes\n  inserters: {\n    text: (tr, content = {text: '', annotations: []}) =\u003e {\n      const text_id = nanoid();\n      tr.create({ id: text_id, type: 'text', content });\n      tr.insert_nodes([text_id]);\n    }\n  },\n  \n  // Returns { commands, keymap } for the editor instance\n  create_commands_and_keymap: (context) =\u003e { ... },\n  \n  // Optional: handle image paste events\n  handle_image_paste: (session, images) =\u003e { ... }\n};\n```\n\n**Key config options:**\n\n- **`generate_id`** - Function that generates unique IDs for new nodes\n- **`node_components`** - Maps each node type from your schema to a Svelte component\n- **`system_components`** - Optional overrides for internal editor components and a slot for your own overlays:\n  - `Overlays` — A Svelte component rendered inside `\u003cSvedit\u003e` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/components/Overlays.svelte`](src/routes/components/Overlays.svelte) for an example.\n  - `NodeGap`, `NodeGapMarkers`, `NodeSelectionMarkers` — Override the default system components if you need custom visuals for node gaps or selection indicators.\n- **`inserters`** - Functions that create blank nodes of each type and set up the selection\n- **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance\n- **`handle_image_paste`** - Optional handler for image paste events\n\nThe config is accessible throughout your app via `session.config`.\n\n## Session\n\nThe `Session` class manages your content graph, selection state, and history. See [`src/lib/Session.svelte.js`](src/lib/Session.svelte.js) for the full API.\n\n### Immutable state\n\nDocument content (`session.doc`) and selection (`session.selection`) are **immutable** with **copy-on-write** semantics. When a change is made, only the modified parts are copied — unchanged nodes keep their original references. This avoids the overhead of reactive proxies (using Svelte's `$state.raw`) since state is reassigned rather than mutated. Also, `console.log(session.get(some_node_id))` gives you a readable raw object, not a proxy.\n\n### Creating a session\n\n```js\nimport { Session } from 'svedit';\n\nconst session = new Session(schema, doc, config);\n```\n\n### Reading the graph\n\n```js\nsession.get(['page_1', 'body'])         // =\u003e ['nav_1', 'paragraph_1', 'list_1']\nsession.get(['nav_1'])                  // =\u003e { id: 'nav_1', type: 'nav', ... }\nsession.get('nav_1')                    // =\u003e shorthand for above (single node ID)\nsession.inspect(['page_1', 'body'])     // =\u003e { kind: 'property', type: 'node_array', node_types: [...] }\nsession.kind(node)                      // =\u003e 'text', 'block', or 'annotation'\n```\n\n### Selection and state\n\n```js\nsession.selection                       // Current selection (text, node, or property)\nsession.selected_node                   // The currently selected node (derived)\nsession.active_annotation('strong')     // Check if annotation is active at caret\nsession.can_insert('paragraph')         // Check if node type can be inserted\nsession.available_annotation_types      // Annotation types allowed at current selection (derived)\n```\n\n### Making changes\n\n```js\nconst tr = session.tr;                  // Create a transaction\ntr.set(['nav_1', 'label'], 'Home');\ntr.insert_nodes(['new_node_id']);\nsession.apply(tr);                      // Apply the transaction\n```\n\n#### Batching history entries\n\nBy default, every `session.apply(tr)` creates a new undo/redo entry. Pass `{ batch: true }` to merge the transaction into the previous history entry instead — useful for continuous interactions like dragging, where you want the entire gesture to undo as one step.\n\n```js\nsession.apply(tr, { batch: true });\n```\n\nBatched transactions merge into the current history entry as long as they arrive within a 2-second window of the batch start. After 2 seconds of inactivity, the next `apply` starts a fresh entry. To force a new entry immediately (e.g. on pointer up), reset the batch timer:\n\n```js\nsession.last_batch_started = undefined;\n```\n\n### History\n\n```js\nsession.can_undo                        // Boolean (derived)\nsession.can_redo                        // Boolean (derived)\nsession.undo()\nsession.redo()\n```\n\n### Detecting unsaved changes\n\nBecause document state is immutable, you can detect unsaved changes by comparing references. When a change is made, `session.doc` gets a new reference — unchanged documents keep the same reference.\n\n```js\nlet last_saved_doc = $state(null);\nlet has_unsaved_changes = $derived.by(() =\u003e {\n  if (!last_saved_doc) {\n    // No save yet — use undo history as indicator\n    return session.can_undo;\n  } else {\n    // Compare current doc reference against last saved\n    return last_saved_doc !== session.doc;\n  }\n});\n\nfunction save() {\n  // ... save to server ...\n  last_saved_doc = session.doc;\n}\n```\n\nThis works because of Svedit's copy-on-write strategy: only modified parts of the document are copied, so reference equality is a reliable and efficient way to detect changes. You can use `has_unsaved_changes` to show/hide a save button, display a dirty indicator, or warn before navigating away.\n\n### Utilities\n\n```js\nsession.doc.document_id                 // The document's root ID\nsession.generate_id()                   // Generate a new unique ID\nsession.config                          // Access the config object\nsession.validate_doc()                  // Validate all nodes against schema\nsession.traverse(node_id)               // Get all nodes reachable from a node\nsession.select_parent()                 // Select parent of current selection\n```\n\n## Transforms\n\nTransforms are pure functions that modify a transaction. They encapsulate common editing operations like breaking text nodes, joining nodes, or inserting new content.\n\nTransforms take a transaction (`tr`) as their parameter and return `true` if successful or `false` if the transform cannot be applied (e.g., wrong selection type or invalid state).\n\n```js\n// Example: break a text node at the caret\nimport { break_text_node } from 'svedit';\n\nconst tr = session.tr;\nconst success = break_text_node(tr);\nif (success) {\n  session.apply(tr);\n}\n```\n\n### Built-in transforms\n\nSvedit provides several core transforms in [`src/lib/transforms.svelte.js`](src/lib/transforms.svelte.js):\n\n- `break_text_node(tr)` - Split a text node at the caret position\n- `join_text_node(tr)` - Join current text node with the previous one\n- `insert_default_node(tr)` - Insert a new node at the current selection\n\n### Composability\n\nTransforms are composable. You can build higher-level transforms from lower-level ones:\n\n```js\nfunction custom_transform(tr) {\n  // Compose multiple transforms\n  if (!break_text_node(tr)) return false;\n  if (!insert_default_node(tr)) return false;\n  return true;\n}\n```\n\n### Writing your own transforms\n\nYou're encouraged to write custom transforms for your application's specific needs. Keep them pure functions that operate on the transaction object:\n\n```js\nfunction insert_heading(tr) {\n  const selection = tr.selection;\n  \n  if (selection?.type !== 'node') return false;\n  \n  // Create and insert a heading node\n  const heading_id = tr.generate_id();\n  tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });\n  tr.insert_nodes([heading_id]);\n  \n  return true;\n}\n```\n\n## Transaction\n\nTransactions group multiple operations into atomic units that can be applied and undone as one. They provide the same read API as sessions (`tr.get()`, `tr.inspect()`, `tr.kind()`, `tr.generate_id()`), so transforms can query document state directly. See [`src/lib/Transaction.svelte.js`](src/lib/Transaction.svelte.js) for the full API.\n\n### Basic usage\n\n```js\nconst tr = session.tr;                      // Create a new transaction\ntr.set(['node_1', 'title'], 'New Title');   // Modify properties\nsession.apply(tr);                          // Apply atomically\n```\n\n### Node operations\n\n```js\n// Create a new node (must include all required properties from schema)\ntr.create({ id: 'paragraph_1', type: 'paragraph', content: { text: '', annotations: [] } });\n\n// Delete a node (cascades to unreferenced child nodes)\ntr.delete('paragraph_26');\n\n// Insert nodes at current node selection\ntr.insert_nodes(['paragraph_1', 'list_1']);\n\n// Build a subgraph from existing nodes (generates new IDs)\nconst new_node_id = tr.build('the_list', {\n  first_item: {\n\t\tid: 'first_item',\n\t\ttype: 'list_item',\n\t\tcontent: node.content\n\t},\n\tthe_list: {\n\t\tid: 'the_list',\n\t\ttype: 'list',\n\t\tlist_items: ['first_item']\n\t}\n});\n```\n\n### Text operations\n\n```js\n// Insert text at caret (replaces selection if expanded)\ntr.insert_text('Hello');\n\n// Toggle annotation on selected text\ntr.annotate_text('strong');\ntr.annotate_text('link', { href: 'https://example.com' });\n\n// Delete selected text or nodes\ntr.delete_selection();\n```\n\n### Selection\n\n```js\n// Set the selection after operations\ntr.set_selection({\n  type: 'text',\n  path: ['node_1', 'content'],\n  anchor_offset: 0,\n  focus_offset: 5\n});\n```\n\nAll transaction methods return `this` for chaining:\n\n```js\ntr.create(node)\n  .insert_nodes([node.id])\n  .set_selection(new_selection);\n```\n\n## Commands\n\nCommands provide a structured way to implement user actions. Commands are stateful and UI-aware, unlike transforms which are pure functions.\n\nThere are two types of commands in Svedit:\n- **Document-scoped commands** - Bound to a specific Svedit instance/document and only active when that editor has focus\n- **App-level commands** - Operate at the application level, independent of any specific document\n\nLet's start with document-scoped commands, which are the foundation of the editing experience.\n\n### Document-scoped commands\n\nDocument-scoped commands operate on a specific document and have access to its selection, content, and editing state through a context object.\n\n#### Creating a document-scoped command\n\nExtend the `Command` base class and implement the `is_enabled()` and `execute()` methods:\n\n```js\nimport { Command } from 'svedit';\n\nclass ToggleStrongCommand extends Command {\n  is_enabled() {\n    return this.context.editable \u0026\u0026 this.context.session.selection?.type === 'text';\n  }\n\n  execute() {\n    this.context.session.apply(this.context.session.tr.annotate_text('strong'));\n  }\n}\n```\n\n#### Document command context\n\nDocument-scoped commands receive a `context` object with access to the Svedit instance's state:\n\n- `context.session` - The current session instance\n- `context.editable` - Whether the editor is in edit mode\n- `context.canvas_el` - The DOM element of the Svedit editor canvas\n- `context.is_composing` - Whether IME composition is currently taking place\n\n### Command lifecycle methods\n\n**`is_enabled(): boolean`**\n\nDetermines if the command can currently be executed. This is automatically evaluated and exposed as the `disabled` derived property, which can be used to disable UI elements.\n\n```js\nis_enabled() {\n  return this.context.editable \u0026\u0026 this.context.session.selection?.type === 'text';\n}\n```\n\n**`execute(): void | Promise\u003cvoid\u003e`**\n\nExecutes the command's action. Can be synchronous or asynchronous.\n\n```js\nexecute() {\n  const tr = this.context.session.tr;\n  tr.insert_text('Hello');\n  this.context.session.apply(tr);\n}\n```\n\n#### Built-in document commands\n\nSvedit provides several [core commands](src/lib/Command.svelte.js) out of the box:\n\n- `UndoCommand` - Undo the last change\n- `RedoCommand` - Redo the last undone change\n- `SelectParentCommand` - Select the parent of the current selection\n- `ToggleAnnotationCommand` - Toggle text annotations (bold, italic, etc.)\n- `AddNewLineCommand` - Insert newline character in text\n- `BreakTextNodeCommand` - Split text node at caret\n- `SelectAllCommand` - Progressively expand selection\n- `InsertDefaultNodeCommand` - Insert a new node at caret\n\n#### Using document commands\n\nCommands are created by passing them a context object from the Svedit component. See a complete example in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) in the `create_commands_and_keymap` configuration function:\n\n```js\ncreate_commands_and_keymap: (context) =\u003e {\n  const commands = {\n    undo: new UndoCommand(context),\n    redo: new RedoCommand(context),\n    toggle_strong: new ToggleAnnotationCommand('strong', context),\n    toggle_emphasis: new ToggleAnnotationCommand('emphasis', context),\n    // ... more commands\n  };\n\n  const keymap = define_keymap({\n    'meta+z,ctrl+z': [commands.undo],\n    'meta+b,ctrl+b': [commands.toggle_strong],\n    // ... more keybindings\n  });\n\n  return { commands, keymap };\n}\n```\n\nBind commands to UI elements in your components:\n\n```svelte\n\u003cbutton \n  disabled={document_commands.toggle_strong.disabled}\n  class:active={document_commands.toggle_strong.active}\n  onclick={() =\u003e document_commands.toggle_strong.execute()}\u003e\n  Bold\n\u003c/button\u003e\n```\n\n#### Derived state in commands\n\nCommands can have derived state for reactive UI binding. The `active` property in toggle commands is a common pattern:\n\n```js\nclass ToggleEmphasisCommand extends Command {\n  // Automatically recomputes when annotation state changes\n  active = $derived(this.context.session.active_annotation('emphasis'));\n\n  is_enabled() {\n    return this.context.editable \u0026\u0026 this.context.session.selection?.type === 'text';\n  }\n\n  execute() {\n    this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));\n  }\n}\n```\n\nThe `disabled` property is automatically derived from `is_enabled()` on all commands.\n\n\n#### DOM access in commands\n\nCommands can access the DOM through the context or global APIs:\n\n```js\nclass FocusNextSelectableCommand extends Command {\n  execute() {\n    const selectables = this.context.canvas_el.querySelectorAll('.svedit-selectable');\n    const next = selectables[0]; // Find next based on current selection\n    const path = next.closest('[data-path]').dataset.path.split('.');\n    this.context.session.selection = { type: 'text', path, anchor_offset: 0, focus_offset: 0 };\n  }\n}\n```\n\n### App-level commands and scope hierarchy\n\nWhile document-scoped commands operate on a specific Svedit instance, app-level commands operate at the application level and handle concerns like saving, loading, switching between edit/view modes, or managing multiple documents.\n\n#### Understanding the scope stack\n\nSvedit uses a scope hierarchy (scope stack) to manage which commands are active at any given time:\n\n1. **App-level scope** (top level) - Commands that are always available, independent of document focus\n2. **Document-level scope** (per Svedit instance) - Commands bound to a specific document/editor\n\nWhen a Svedit instance gains focus:\n- The previous document's scope is **popped** from the stack (its commands become inactive)\n- The newly focused document's scope is **pushed** onto the stack (its commands become active)\n\nThis means commands automatically work with the correct document based on focus.\n\n#### Creating app-level commands\n\nApp-level commands have their own context, separate from any specific document:\n\n```js\nimport { Command } from 'svedit';\n\nclass SaveCommand extends Command {\n  is_enabled() {\n    return this.context.editable;\n  }\n\n  async execute() {\n    await this.context.save_all_documents();\n    this.context.show_notification('All changes saved');\n  }\n}\n\nclass ToggleEditModeCommand extends Command {\n  is_enabled() {\n    return !this.context.editable;\n  }\n\n  execute() {\n    this.context.editable = true;\n  }\n}\n```\n\n#### App-level context\n\nThe app-level context contains application-wide state and methods:\n\n```js\nconst app_context = {\n  get editable() {\n    return editable; // App-level editable state\n  },\n  set editable(value) {\n    editable = value;\n  },\n  get session() {\n    return session;\n  },\n  get app_el() {\n    return app_el;\n  }\n};\n\nconst app_commands = {\n  save: new SaveCommand(app_context),\n  toggle_edit: new ToggleEditCommand(app_context)\n};\n```\n\n## Scope-aware keyboard shortcuts\n\nThe KeyMapper manages keyboard shortcuts using a scope-based stack system. Scopes are tried from top to bottom (most recent to least recent), allowing more specific keymaps to override general ones.\n\n### Basic usage\n\n```js\nimport { KeyMapper, define_keymap } from 'svedit';\n\nconst key_mapper = new KeyMapper();\n\n// Define a keymap\nconst keymap = define_keymap({\n  'meta+z,ctrl+z': [document_commands.undo],\n  'meta+b,ctrl+b': [document_commands.bold],\n  'enter': [document_commands.break_text_node]\n});\n\n// Push the keymap onto the scope stack\nkey_mapper.push_scope(keymap);\n\n// Handle keydown events\nwindow.addEventListener('keydown', (event) =\u003e {\n  key_mapper.handle_keydown(event);\n});\n```\n\n### Key syntax\n\n- **Multiple modifiers**: `meta+shift+z`, `ctrl+alt+k`\n- **Cross-platform**: `meta+z,ctrl+z` (tries Meta+Z first, then Ctrl+Z)\n- **Modifiers**: `meta`, `ctrl`, `alt`, `shift`\n- **Keys**: Any key name (e.g., `a`, `enter`, `escape`, `arrowup`)\n\n### Command arrays\n\nCommands are wrapped in arrays to support fallback behavior:\n\n```js\ndefine_keymap({\n  'meta+b,ctrl+b': [\n    document_commands.bold,      // Try this first\n    document_commands.fallback   // Use this if first is disabled\n  ]\n});\n```\n\n### Scope stack\n\nUse `push_scope()` and `pop_scope()` to manage different keyboard contexts:\n\n```js\n// App-level keymap (always active)\nconst app_keymap = define_keymap({\n  'meta+s,ctrl+s': [app_commands.save],\n  'meta+n,ctrl+n': [app_commands.new_document]\n});\nkey_mapper.push_scope(app_keymap);\n\n// Document-level keymap (active when editor has focus)\nconst doc_keymap = define_keymap({\n  'meta+z,ctrl+z': [document_commands.undo],\n  'meta+b,ctrl+b': [document_commands.bold]\n});\n\n// When editor gains focus:\nkey_mapper.push_scope(doc_keymap);\n\n// When editor loses focus:\nkey_mapper.pop_scope();\n```\n\nThe KeyMapper tries scopes from top to bottom, so push more specific keymaps last.\n\n\n## Selection\n\nSelections are at the heart of Svedit. There are just three types of selections:\n\n### Terminology note\n\n- Use \"node\" as the domain term.\n- Use \"node caret\" for a collapsed node selection.\n- Use \"node gap\" for the DOM landing zone between nodes.\n\n1. **Text Selection**: A text selection spans across a range of characters in a string. E.g. the below example has a collapsed caret at position 1 in a text property 'content'.\n\n  ```js\n  {\n    type: 'text',\n    path: ['page_1234', 'body', 0, 'content'],\n    anchor_offset: 1,\n    focus_offset: 1\n  }\n  ```\n\n2. **Node Selection**: A node selection spans across a range of nodes inside a node_array. The below example selects the nodes at index 3 and 4.\n\n  ```js\n  {\n    type: 'node',\n    path: ['page_1234', 'body'],\n    anchor_offset: 2,\n    focus_offset: 4\n  }\n  ```\n\n3. **Property Selection**: A property selection addresses one particular property of a node.\n\n  ```js\n  {\n    type: \"property\",\n    path: [\n      \"page_1\",\n      \"body\",\n      11,\n      \"image\"\n    ]\n  }\n  ```\n\nYou can access the current selection through `session.selection` anytime. And you can programmatically set the selection using `session.selection = new_selection`.\n\n## Rendering\n\nNow you can start making your Svelte pages in-place editable by wrapping your design inside the `\u003cSvedit\u003e` component.\n\n```svelte\n\u003cSvedit {session} path={[session.doc.document_id]} editable={true} /\u003e\n```\n\n## Node components\n\nNode components are Svelte components that render specific node types in your document. Each node component receives a `path` prop and uses the `\u003cNode\u003e` wrapper component along with property components to render the node's content.\n\n### Basic structure\n\nA typical node component follows this pattern:\n\n```svelte\n\u003cscript\u003e\n  import { Node, AnnotatedTextProperty } from 'svedit';\n  let { path } = $props();\n\u003c/script\u003e\n\n\u003cNode {path}\u003e\n  \u003cdiv class=\"my-node\"\u003e\n    \u003cAnnotatedTextProperty path={[...path, 'content']} /\u003e\n  \u003c/div\u003e\n\u003c/Node\u003e\n```\n\n### The `\u003cNode\u003e` wrapper\n\nEvery node component must wrap its content in the `\u003cNode\u003e` component. This wrapper:\n- Registers the node with the editor\n- Handles selection and caret behavior\n- Provides the foundation for editing interactions\n\n### Property components\n\nSvedit provides specialized components for rendering different property types:\n\n**`\u003cAnnotatedTextProperty\u003e`** - For editable text content with inline formatting:\n\n```svelte\n\u003cAnnotatedTextProperty\n  tag=\"p\"\n  class=\"body\"\n  path={[...path, 'content']}\n  placeholder=\"Enter text here\"\n/\u003e\n```\n\n**`\u003cNodeArrayProperty\u003e`** - For container properties that hold multiple nodes:\n\n```svelte\n\u003cNodeArrayProperty \n  class=\"list-items\"\n  path={[...path, 'list_items']} \n/\u003e\n```\n\n**`\u003cCustomProperty\u003e`** - For custom properties like images or other non-text content:\n\n```svelte\n\u003cCustomProperty class=\"image-wrapper\" path={[...path, 'image']}\u003e\n  \u003cdiv contenteditable=\"false\"\u003e\n    \u003cimg src={node.image} alt={node.title.text} /\u003e\n  \u003c/div\u003e\n\u003c/CustomProperty\u003e\n```\n\n### Accessing node data\n\nUse the Svedit context to access node data:\n\n```svelte\n\u003cscript\u003e\n  import { getContext } from 'svelte';\n  const svedit = getContext('svedit');\n  \n  let { path } = $props();\n  let node = $derived(svedit.session.get(path));\n  let layout = $derived(node.layout || 1);\n\u003c/script\u003e\n```\n\n### Example: Text component\n\nHere's a complete example of a text node component that supports multiple layouts:\n\n```svelte\n\u003cscript\u003e\n  import { getContext } from 'svelte';\n  import { Node, AnnotatedTextProperty } from 'svedit';\n\n  const svedit = getContext('svedit');\n  let { path } = $props();\n  let node = $derived(svedit.session.get(path));\n  let layout = $derived(node.layout || 1);\n  let tag = $derived(layout === 1 ? 'p' : `h${layout - 1}`);\n\u003c/script\u003e\n\n\u003cNode {path}\u003e\n  \u003cdiv class=\"text layout-{layout}\"\u003e\n    \u003cAnnotatedTextProperty\n      {tag}\n      class=\"body\"\n      path={[...path, 'content']}\n      placeholder=\"Enter text\"\n    /\u003e\n  \u003c/div\u003e\n\u003c/Node\u003e\n```\n\n### Example: List component\n\nA simple list component that renders child items:\n\n```svelte\n\u003cscript\u003e\n  import { Node, NodeArrayProperty } from 'svedit';\n  let { path } = $props();\n\u003c/script\u003e\n\n\u003cNode {path}\u003e\n  \u003cdiv class=\"list\"\u003e\n    \u003cNodeArrayProperty path={[...path, 'list_items']} /\u003e\n  \u003c/div\u003e\n\u003c/Node\u003e\n```\n\n### Registering node components\n\nNode components are registered in the document config's `node_components` map:\n\n```js\nconst session_config = {\n  node_components: {\n    Text,\n    Story,\n    List,\n    ListItem,\n    // ... other components\n  }\n}\n```\n\nThe keys must be PascalCase versions of the snake_case node types in your schema. Svedit converts node types automatically (e.g. `list_item` → `ListItem`, `image_grid` → `ImageGrid`), so a node with `type: \"list_item\"` will look for a component registered as `ListItem`.\n\n## Mastering contenteditable\n\nSvedit relies on the contenteditable attribute to make elements editable. The below example shows you\na simplified version of the markup of `\u003cNodeGap\u003e` and why it is implemented the way it is.\n\n```html\n\u003cdiv contenteditable=\"true\"\u003e\n  \u003cdiv class=\"some-wrapper\"\u003e\n    \u003c!--\n      Putting a \u003cbr\u003e tag into a div gives you a single addressable caret position.\n\n      Adding a \u0026ZeroWidthSpace; (or any character) here will lead to 2 caret\n      positions (one before, and one after the character)\n\n      Using \u003cwbr\u003e will make it only addressable for ArrowLeft and ArrowRight, but not ArrowUp and ArrowDown.\n      And using \u003cspan\u003e\u003c/span\u003e will not make it addressable at all.\n\n      Svedit uses this behavior for node gaps, and when an\n      \u003cAnnotatedTextProperty\u003e is empty.\n    --\u003e\n    \u003cdiv class=\"node-gap\"\u003e\u003cbr\u003e\u003c/div\u003e\n    \u003c!--\n      If you create a contenteditable=\"false\" island, there needs to be some content in it,\n      otherwise it will create two additional caret positions. One before, and another one\n      after the island.\n\n      The Svedit demo uses this technique in `\u003cNodeGap\u003e` to create a node-caret\n      visualization, that doesn't mess with the contenteditable caret positions.\n    --\u003e\n    \u003cdiv contenteditable=\"false\" class=\"node-caret\"\u003e\u0026ZeroWidthSpace;\u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\nFurther things to consider:\n\n- If you make a sub-tree `contenteditable=\"false\"`, be aware that you can't create a `contenteditable=\"true\"` segment somewhere inside it. Svedit can only work reliably when there's one contenteditable=\"true\" at root (it's set by `\u003cSvedit`\u003e)\n- `\u003cAnnotatedTextProperty\u003e` and `\u003cCustomProperty\u003e` must not be wrapped in `contenteditable=\"false\"` to work properly.\n- Never apply `position: relative` to the direct parent of `\u003cAnnotatedTextProperty\u003e`, it will cause a [weird Safari bug](https://bsky.app/profile/michaelaufreiter.com/post/3lxvdqyxc622s) to destroy the DOM.\n- Never apply `position: relative`, `position: absolute`, `position: fixed` to `\u003cNode.svelte\u003e` (data-type=\"node\") in edit mode. Only `position: static` is permitted to allow css anchor positioning queries to resolve correctly.\n- Never use an `\u003ca\u003e` tag inside a `contenteditable=\"true\"` element, as it will cause unexpected behavior. Make it a `\u003cdiv\u003e` while editing, and an `\u003ca\u003e` in read-only mode (when `svedit.editable` is `false` ).\n- Avoid CSS selectors like `:last-child` or `:first-child` on nodes (e.g., `[data-type=\"node\"]:last-child`) because `\u003cNodeGap\u003e` elements are inserted in edit mode and will become the actual first or last child. This can cause unexpected layout shifts (e.g., if you have `.paragraph-node:last-child { margin-bottom: 10px }`, the margin won't apply as expected).\n- Avoid adding css `margin` to nodes inside node arrays when using flex or grid layouts. Use `gap` on the container instead. This ensure `NodeGap` and `NodeGapMarkers` render consistently. If you need to add a margin, add it to child element of Node.\n\n### Node array CSS tokens\n\n`\u003cNodeArrayProperty\u003e` renders a `[data-type=\"node_array\"]` element. Two CSS custom properties control how gaps and carets behave inside it:\n\n**`--row`** — Tell Svedit whether a node array flows horizontally (`1`) or vertically (`0`). Gaps, carets, and markers all switch orientation accordingly. Defaults to `0` on the Svedit canvas. Set it on the node array or an ancestor:\n\n```css\n.my-horizontal-layout :global(.grid-items) {\n  --row: 1;\n  display: flex;\n  flex-wrap: wrap;\n}\n```\n\n**`--node-caret-boundary`** — Edge gaps (before the first node, after the last) extend outward beyond the container to enlarge their click target. If the node array has neighboring elements (e.g. a preceding node or surrounding UI), the outward extension can overlap them:\n\n```\nWithout --node-caret-boundary                  With --node-caret-boundary\n\n: . . . . . . . . . . . . . . . :      \n: +---------------------------+ :             +-----------------------------+\n: | Toolbar / UI              | : ← overlap   | Toolbar / UI                |\n: +---------------------------+ :             +-----------------------------+\n:                               :      \n:     edge gap (unbounded)      :   boudary → +-----------------------------+\n: . . . . . . . . . . . . . . . :             | : . . . . . . . . . . . . : |\n                                              | :   edge gap (clamped)    : |\n  +---------------------------+               | : . . . . . . . . . . . . : |\n  |  +---------------------+  |               |  +-----------------------+  |\n  |  | First node          |  |               |  | First node            |  |\n  |  +---------------------+  |               |  +-----------------------+  |\n  |    gap between nodes      |               |    gap between nodes        |\n  |  +---------------------+  |               |  +-----------------------+  |\n  |  | Last node           |  |               |  | Last node             |  |\n  |  +---------------------+  |               |  +-----------------------+  |\n  +---------------------------+               | : . . . . . . . . . . . . : |\n                                              | :   edge gap (clamped)    : |\n: . . . . . . . . . . . . . . . :             | : . . . . . . . . . . . . : |\n:     edge gap (unbounded)      :   boudary → +-----------------------------+\n:                               :      \n: +---------------------------+ :             +-----------------------------+\n: | Footer / UI               | : ← overlap   | Footer / UI                 |\n: +---------------------------+ :             +-----------------------------+\n: . . . . . . . . . . . . . . . : \n```\n\nSet `--node-caret-boundary` to the `anchor-name` of a parent element to clamp edge gaps to that element's edges:\n\n```css\n.editor-wrapper {\n  anchor-name: --editor-boundary;\n  padding: 24px;\n}\n.editor-wrapper [data-type=\"node_array\"] {\n  --node-caret-boundary: --editor-boundary;\n}\n```\n\nWhen set, edge gaps clamp to the boundary element's edges instead. When not set, gaps extend to the containing block edge (default).\n\nSince `--node-caret-boundary` inherits to nested node arrays, you may need to unset it on inner containers that should not be clamped:\n\n```css\n.inner-container [data-type=\"node_array\"] {\n  --node-caret-boundary: initial;\n}\n```\n\nFor per-axis control, use `--node-caret-boundary-x` (left/right) and `--node-caret-boundary-y` (top/bottom). They take precedence over `--node-caret-boundary` when set:\n\n```css\n.editor-wrapper [data-type=\"node_array\"] {\n  --node-caret-boundary-x: --editor-boundary;\n}\n```\n\n## Beyond the README\n\nThe source code is compact and readable — less than 3000 LOC across a handful of files. We encourage you to explore it. The files in [`src/lib`](./src/lib) are the library code, while the files in [`src/routes`](./src/routes) are example code you can copy and adapt to your needs.\n\n## Developing Svedit\n\nOnce you've cloned the Svedit repository and installed dependencies with `npm install`, start a development server:\n\n```bash\nnpm run dev\n```\n\n## Building\n\nTo create a production version of your app:\n\n```bash\nnpm run build\n```\n\nYou can preview the production build with `npm run preview`.\n\n## Contributing\n\nContributions are very welcome! Bug reports, bug fixes, and small PRs (a couple of lines of code) don't need any ceremony — just go for it. What follows applies to larger changes and new features.\n\nI take long-term maintainability very seriously. Much like the SQLite project, I prioritize minimalism and code quality over features. This means I may decline pull requests — even good ones — if they don't fit my vision for Svedit at a given point in time. I'll always try to articulate my reasons, but sometimes it comes down to intuition more than logic. Please don't take it personally — it doesn't mean your idea is bad, just that I don't see it belonging in core right now.\n\n### How to contribute a feature\n\n1. **Start with your requirements.** Open an issue describing what you need and why. Wait for a green light that this is something that belongs in core before writing code.\n2. **Explore approaches.** For non-trivial features, there may be multiple ways to solve the problem. Discuss trade-offs before committing to one direction — I may ask you to explore alternatives first.\n3. **Prove feasibility.** Make a small PR that solves the root of the problem — no optimizations, no UX polish. This lets me evaluate the impact on the library and give you early feedback if your approach conflicts with Svedit's design decisions.\n4. **Plan the finish.** Once feasibility is approved, outline the remaining steps and wait for another green light. Then we iterate together until it's ready to merge.\n\n**Please don't work on a feature for a long time without checking in regularly.** I have a much lower tolerance for complexity than most developers, and I need to be able to digest changes in small pieces. Going off and making a large rewrite without involving me will likely be frustrating for both of us.\n\n### Sponsorship\n\nAnother great way to help is to donate or sponsor the project, so I can buy more dedicated development time. Email me at michael@letsken.com.\n\n## Beta version\n\nIt's still early. Expect bugs. Expect missing features. Expect the need for more work on your part to make this fit for your use case.\n\n## Credits\n\nSvedit is led by [Michael Aufreiter](https://michaelaufreiter.com) with guidance and support from [Johannes Mutter](https://mutter.co).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmichael%2Fsvedit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmichael%2Fsvedit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmichael%2Fsvedit/lists"}