{"id":51131992,"url":"https://github.com/thanos/ex_editor","last_synced_at":"2026-06-25T13:31:13.580Z","repository":{"id":323221877,"uuid":"1092365090","full_name":"thanos/ex_editor","owner":"thanos","description":"A pure elixir headless editor","archived":false,"fork":false,"pushed_at":"2026-04-10T17:56:25.000Z","size":16243,"stargazers_count":0,"open_issues_count":12,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-19T23:22:45.333Z","etag":null,"topics":["editor","editor-plugin","elixir","js","liveview","phoenix","renderless-components","siwyg-editor"],"latest_commit_sha":null,"homepage":"","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thanos.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.md","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":"2025-11-08T13:49:41.000Z","updated_at":"2026-04-10T17:55:54.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thanos/ex_editor","commit_stats":null,"previous_names":["thanos/ex_editor"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/thanos/ex_editor","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thanos%2Fex_editor","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thanos%2Fex_editor/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thanos%2Fex_editor/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thanos%2Fex_editor/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thanos","download_url":"https://codeload.github.com/thanos/ex_editor/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thanos%2Fex_editor/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34778079,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-25T02:00:05.521Z","response_time":101,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"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":["editor","editor-plugin","elixir","js","liveview","phoenix","renderless-components","siwyg-editor"],"created_at":"2026-06-25T13:31:13.484Z","updated_at":"2026-06-25T13:31:13.570Z","avatar_url":"https://github.com/thanos.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ExEditor\n\n[![Hex.pm](https://img.shields.io/hexpm/v/ex_editor.svg)](https://hex.pm/packages/ex_editor)\n[![Hex.pm](https://img.shields.io/hexpm/dt/ex_editor.svg)](https://hex.pm/packages/ex_editor)\n[![Hex.pm](https://img.shields.io/hexpm/l/ex_editor.svg)](https://hex.pm/packages/ex_editor)\n[![HexDocs.pm](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/ex_editor)\n\nA headless code editor library for Phoenix LiveView applications with a plugin system for extensibility.\n\n**[Live Demo](https://ex-editor.fly.dev)**\n\n## Features\n\n- **Headless Architecture** - Core editing logic separate from UI concerns\n- **LiveView Component** - Ready-to-use `\u003c.live_editor /\u003e` component with syntax highlighting\n- **Responsive Highlighting** - Syntax highlighting always visible and updates within ~50ms\n- **Incremental Diffs** - Send only changed content (4-6x smaller payloads) instead of full text\n- **Native Caret** - Uses browser's native cursor instead of overlay (no disappearing cursor)\n- **Line Numbers** - JS-managed line number gutter that updates instantly without server round-trip\n- **Double-Buffer Rendering** - Invisible textarea with visible highlighted layer for seamless editing\n- **Scroll Synchronization** - Textarea, highlight layer, and gutter stay perfectly aligned during scrolling\n- **Line-Based Document Model** - Efficient text manipulation with line operations\n- **Plugin System** - Extend functionality through a simple behavior-based plugin API\n- **Undo/Redo Support** - Built-in history management with configurable stack size\n- **Syntax Highlighting** - Built-in highlighters for Elixir and JSON, easily extensible\n- **Comprehensive Testing** - 285 tests, 88.7%+ coverage with full LiveComponent integration tests\n\n## Installation\n\n### From Hex (when published)\n\nAdd `ex_editor` to your list of dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n  [\n    {:ex_editor, \"~\u003e 0.3.0\"}\n  ]\nend\n```\n\n### From GitHub\n\n```elixir\ndef deps do\n  [\n    {:ex_editor, github: \"thanos/ex_editor\"}\n  ]\nend\n```\n\nThen run:\n\n```bash\nmix deps.get\n```\n\n## Quick Start\n\n### Phoenix LiveView Integration\n\nAdd the JavaScript hook to your `assets/js/app.js`:\n\n```javascript\nimport EditorHook from \"ex_editor/hooks/editor\"\n\nlet liveSocket = new LiveSocket(\"/live\", Socket, {\n  hooks: { EditorHook }\n})\n```\n\nImport the CSS in your `assets/css/app.css`:\n\n```css\n@import \"ex_editor/css/editor\";\n```\n\nUse the component in your LiveView:\n\n```elixir\ndefmodule MyAppWeb.EditorLive do\n  use MyAppWeb, :live_view\n\n  def mount(_params, _session, socket) do\n    {:ok, assign(socket, :code, \"def hello, do: :world\")}\n  end\n\n  def render(assigns) do\n    ~H\"\"\"\n    \u003cExEditorWeb.LiveEditor.live_editor\n      id=\"code-editor\"\n      content={@code}\n      language={:elixir}\n      on_change=\"code_changed\"\n    /\u003e\n    \"\"\"\n  end\n\n  def handle_event(\"code_changed\", %{\"content\" =\u003e new_code}, socket) do\n    {:noreply, assign(socket, :code, new_code)}\n  end\nend\n```\n\n### Component Options\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `:id` | `string` | required | Unique identifier for the editor |\n| `:content` | `string` | `\"\"` | Initial content string |\n| `:editor` | `Editor.t()` | - | Pre-existing Editor struct |\n| `:language` | `atom` | `:elixir` | Syntax highlighting language |\n| `:on_change` | `string` | `\"change\"` | Event name for content changes |\n| `:readonly` | `boolean` | `false` | Read-only mode |\n| `:line_numbers` | `boolean` | `true` | Show/hide line numbers |\n| `:class` | `string` | `\"\"` | Additional CSS classes |\n| `:debounce` | `integer` | `300` | Debounce time in milliseconds |\n\n### Supported Languages\n\nBuilt-in highlighters:\n\n- `:elixir` - Elixir syntax\n- `:json` - JSON syntax\n\n## Backpex Integration\n\nExEditor integrates seamlessly with [Backpex](https://hexdocs.pm/backpex) admin panels as a custom field for code editing.\n\n### Setup\n\n1. Create a custom field module in your Backpex resource:\n\n```elixir\ndefmodule MyAppWeb.Admin.Fields.CodeEditor do\n  use Backpex.Field, config_schema: []\n\n  @impl Backpex.Field\n  def render_value(assigns) do\n    field_value = Map.get(assigns.item, assigns.name)\n\n    ~H\"\"\"\n    \u003cdiv class=\"border border-gray-300 rounded-lg overflow-hidden bg-slate-900\"\u003e\n      \u003cdiv class=\"ex-editor-wrapper\" style=\"display: flex;\"\u003e\n        \u003cdiv class=\"ex-editor-gutter\"\u003e\n          \u003c%= for num \u003c- 1..line_count(field_value) do %\u003e\n            \u003cdiv class=\"ex-editor-line-number\"\u003e\u003c%= num %\u003e\u003c/div\u003e\n          \u003c% end %\u003e\n        \u003c/div\u003e\n        \u003cdiv class=\"ex-editor-code-area\"\u003e\n          \u003cpre class=\"ex-editor-highlight\"\u003e\u003c%= raw highlight_code(field_value) %\u003e\u003c/pre\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n    \"\"\"\n  end\n\n  @impl Backpex.Field\n  def render_form(assigns) do\n    field_value = assigns.form[assigns.name]\n    content = field_value \u0026\u0026 field_value.value || \"\"\n    assigns = assign(assigns, :content, content)\n\n    ~H\"\"\"\n    \u003cdiv\u003e\n      \u003cLayout.field_container\u003e\n        \u003c:label align={Backpex.Field.align_label(@field_options, assigns, :top)}\u003e\n          \u003cLayout.input_label for={@form[@name]} text={@field_options[:label]} /\u003e\n        \u003c/:label\u003e\n\n        \u003cdiv class=\"border border-gray-300 rounded-lg overflow-hidden mb-2 h-96\"\u003e\n          \u003c.live_component\n            module={ExEditorWeb.LiveEditor}\n            id={\"editor_#{@name}\"}\n            content={@content}\n            language={:elixir}\n            debounce={100}\n            readonly={@readonly}\n          /\u003e\n        \u003c/div\u003e\n\n        \u003cinput\n          type=\"hidden\"\n          name={@form[@name].name}\n          value={@content}\n          id={\"#{@form[@name].id}_editor_sync\"}\n          phx-hook=\"EditorFormSync\"\n          data-field-id={@form[@name].id}\n        /\u003e\n\n        \u003c%= if help_text = Backpex.Field.help_text(@field_options, assigns) do %\u003e\n          \u003cp class=\"text-sm text-gray-500 mt-1\"\u003e\u003c%= help_text %\u003e\u003c/p\u003e\n        \u003c% end %\u003e\n      \u003c/Layout.field_container\u003e\n    \u003c/div\u003e\n    \"\"\"\n  end\n\n  defp line_count(nil), do: 1\n  defp line_count(content) when is_binary(content) do\n    content |\u003e String.split(\"\\n\") |\u003e length()\n  end\n\n  defp highlight_code(nil), do: \"\"\n  defp highlight_code(content) do\n    editor = ExEditor.Editor.new(content: content)\n    editor = ExEditor.Editor.set_highlighter(editor, ExEditor.Highlighters.Elixir)\n    ExEditor.Editor.get_highlighted_content(editor)\n  end\nend\n```\n\n2. Register the field in your Backpex resource:\n\n```elixir\ndef fields do\n  [\n    name: %{module: Backpex.Fields.Text, label: \"Name\"},\n    code: %{\n      module: MyAppWeb.Admin.Fields.CodeEditor,\n      label: \"Code\",\n      help_text: \"Enter your code here\"\n    }\n  ]\nend\n```\n\n3. Add the EditorFormSync hook to your `assets/js/app.js`:\n\n```javascript\nimport EditorFormSync from \"./hooks/editor_form_sync.js\"\n\nlet liveSocket = new LiveSocket(\"/live\", Socket, {\n  hooks: { EditorHook, EditorFormSync }\n})\n```\n\n### Features\n\n- **Full syntax highlighting** in both edit and view modes\n- **Line numbers** displayed with code (with instant updates while editing)\n- **Real-time form synchronization** - changes are automatically synced to the form\n- **Readonly display** - code is syntax-highlighted with line numbers on the show page\n- **Responsive** - editor adapts to container size\n- **Configurable** - debounce, language, and styling options\n\n## Core Editor API\n\n### Basic Usage\n\n```elixir\n# Create a new editor with initial content\neditor = ExEditor.Editor.new(content: \"Hello, World!\\nThis is line 2\")\n\n# Get the current content\nExEditor.Editor.get_content(editor)\n# =\u003e \"Hello, World!\\nThis is line 2\"\n\n# Update content\n{:ok, editor} = ExEditor.Editor.set_content(editor, \"New content here\")\n\n# Work with the underlying document\ndoc = editor.document\nExEditor.Document.line_count(doc)  # =\u003e 1\nExEditor.Document.get_line(doc, 1) # =\u003e {:ok, \"New content here\"}\n```\n\n### Document Operations\n\n```elixir\n# Create a document from text\ndoc = ExEditor.Document.from_text(\"line 1\\nline 2\\nline 3\")\n\n# Insert a new line\n{:ok, doc} = ExEditor.Document.insert_line(doc, 2, \"inserted line\")\n# Now: [\"line 1\", \"inserted line\", \"line 2\", \"line 3\"]\n\n# Replace a line\n{:ok, doc} = ExEditor.Document.replace_line(doc, 1, \"updated line 1\")\n\n# Delete a line\n{:ok, doc} = ExEditor.Document.delete_line(doc, 2)\n\n# Get line count\nExEditor.Document.line_count(doc)  # =\u003e 3\n\n# Convert back to text\nExEditor.Document.to_text(doc)\n# =\u003e \"updated line 1\\nline 2\\nline 3\"\n```\n\n### Using Plugins\n\nCreate a plugin by implementing the `ExEditor.Plugin` behaviour:\n\n```elixir\ndefmodule MyApp.EditorPlugins.MaxLength do\n  @behaviour ExEditor.Plugin\n\n  @max_length 10_000\n\n  @impl true\n  def on_event(:before_change, {_old, new}, editor) do\n    if String.length(new) \u003e @max_length do\n      {:error, :content_too_long}\n    else\n      {:ok, editor}\n    end\n  end\n\n  @impl true\n  def on_event(_event, _payload, editor), do: {:ok, editor}\nend\n```\n\nUse it with your editor:\n\n```elixir\neditor = ExEditor.Editor.new(\n  content: \"Initial content\",\n  plugins: [MyApp.EditorPlugins.MaxLength]\n)\n\n# This will succeed\n{:ok, editor} = ExEditor.Editor.set_content(editor, \"Short text\")\n\n# This will fail if content exceeds max length\n{:error, :content_too_long} = ExEditor.Editor.set_content(editor, long_content)\n```\n\n### Plugin Events\n\n| Event | Payload | Purpose |\n|-------|---------|---------|\n| `:before_change` | `{old_content, new_content}` | Validate/reject changes |\n| `:handle_change` | `{old_content, new_content}` | React to changes |\n| Custom | Any | Application-defined |\n\n## Syntax Highlighting\n\nExEditor includes optional syntax highlighters:\n\n- `ExEditor.Highlighters.Elixir` - Highlights Elixir code\n- `ExEditor.Highlighters.JSON` - Highlights JSON data\n\n```elixir\neditor = ExEditor.Editor.new(content: \"def hello, do: :world\")\neditor = ExEditor.Editor.set_highlighter(editor, ExEditor.Highlighters.Elixir)\nExEditor.Editor.get_highlighted_content(editor)\n# =\u003e \"\u003cspan class=\\\"hl-keyword\\\"\u003edef\u003c/span\u003e ...\"\n```\n\nCreate custom highlighters by implementing the `ExEditor.Highlighter` behaviour.\n\n## Architecture\n\n### Double-Buffer Rendering\n\nThe LiveEditor component uses a \"double-buffer\" technique:\n\n```\n┌─────────────────────────────────────┐\n│  Container (relative positioned)    │\n│  ┌───────────────────────────────┐  │\n│  │ Highlighted Layer (visible)   │  │  Syntax-highlighted code\n│  │   - Line numbers              │  │  with fake cursor\n│  │   - Fake cursor               │  │\n│  └───────────────────────────────┘  │\n│  ┌───────────────────────────────┐  │\n│  │ Textarea Layer (invisible)    │  │  Captures user input\n│  │   - color: transparent        │  │\n│  └───────────────────────────────┘  │\n└─────────────────────────────────────┘\n```\n\nBoth layers share identical styling for perfect sync.\n\n### Document Model\n\nThe `ExEditor.Document` module provides a line-based text representation:\n\n- Lines are stored as a list of strings\n- Line numbers are 1-indexed (line 1 is the first line)\n- Supports all line ending formats (\\n, \\r\\n, \\r)\n- Immutable operations return `{:ok, new_doc}` or `{:error, reason}`\n\n### Editor State\n\nThe `ExEditor.Editor` module manages editor state:\n\n- Wraps a `Document` with metadata\n- Coordinates plugin execution\n- Handles content changes and notifications\n- Provides undo/redo with configurable history size\n- Provides a simple API for UI integration\n\n## Demo Application\n\nSee the included demo application in `demo/` for a complete example.\n\n**[Live Demo](https://ex-editor.fly.dev)**\n\nTo run the demo locally:\n\n```bash\ncd demo\nmix setup\nmix phx.server\n```\n\nThen visit [http://localhost:4000](http://localhost:4000)\n\n## API Documentation\n\nFull API documentation is available on [HexDocs](https://hexdocs.pm/ex_editor).\n\nGenerate docs locally:\n\n```bash\nmix docs\n```\n\n## Development\n\n### Running Tests\n\n```bash\n# Run all tests\nmix test\n\n# Run with coverage\nmix coveralls\n\n# Generate HTML coverage report\nmix coveralls.html\n```\n\n### Code Quality\n\n```bash\n# Format code\nmix format\n\n# Run static analysis\nmix credo --strict\n\n# Run security checks\nmix sobelow\n```\n\n## Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request.\n\n1. Fork the repository\n2. Create your feature branch (`git checkout -b feature/amazing-feature`)\n3. Run tests and ensure they pass (`mix test`)\n4. Commit your changes (`git commit -m 'Add amazing feature'`)\n5. Push to the branch (`git push origin feature/amazing-feature`)\n6. Open a Pull Request\n\n## License\n\nThis project is licensed under the MIT License - see the LICENSE file for details.\n\n## Acknowledgments\n\n- Inspired by the need for headless editor libraries in the Elixir ecosystem\n- Built with Phoenix LiveView in mind\n- Thanks to the Elixir community for feedback and support","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthanos%2Fex_editor","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthanos%2Fex_editor","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthanos%2Fex_editor/lists"}