ElixirMarkdownEditor

Phoenix LiveView components for file browsing and editing with Monaco editor.

ElixirMarkdownEditor screenshot

Provides a ready-to-use file tree, code editor, and integrated file browser backed by a pluggable storage adapter. Drop it into any Phoenix LiveView app — you control where files live (filesystem, S3, database, etc.) by implementing a simple behaviour.

Features

Installation

Add to your mix.exs:

def deps do
  [
    {:elixir_markdown_editor, "~> 0.1.0"}
  ]
end

Or from GitHub:

def deps do
  [
    {:elixir_markdown_editor, github: "Founding-Developers/elixir-markdown-editor"}
  ]
end

Setup

1. Implement the FileStore behaviour

Create an adapter module that implements ElixirMarkdownEditor.FileStore:

defmodule MyApp.LocalFileStore do
  @behaviour ElixirMarkdownEditor.FileStore

  @impl true
  def list_files(scope) do
    # Return {:ok, [file_entry]} where file_entry is:
    # %{name: "file.md", path: "docs/file.md", type: :file, children: nil}
    # %{name: "docs", path: "docs", type: :directory, children: [...]}
  end

  @impl true
  def read_file(scope, path) do
    # Return {:ok, content} or {:error, reason}
  end

  @impl true
  def write_file(scope, path, content) do
    # Return :ok or {:error, reason}
  end

  @impl true
  def create_file(scope, path), do: # ...

  @impl true
  def delete_file(scope, path), do: # ...

  @impl true
  def rename_file(scope, old_path, new_path), do: # ...
end

The scope parameter is opaque — define it however your app needs (org ID, user context, S3 prefix, etc.).

2. Configure the adapter

In config/config.exs:

config :elixir_markdown_editor, :file_store, MyApp.LocalFileStore

3. Import JS hooks

In your assets/js/app.js:

import { FileTreeHook } from "elixir_markdown_editor/assets/js/index.js";

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...otherHooks, FileTreeHook }
});

Note: If installed as a dependency, the path will be ../deps/elixir_markdown_editor/assets/js/index.js. If used as an umbrella app, adjust the relative path accordingly.

4. Import CSS

In your assets/css/app.css:

@source "../deps/elixir_markdown_editor/lib";
@import "../deps/elixir_markdown_editor/assets/css/elixir_markdown_editor.css";

Usage

Integrated file browser

The simplest way to use the library — a side-by-side tree + editor:

defmodule MyAppWeb.FilesLive do
  use MyAppWeb, :live_view
  use ElixirMarkdownEditor.Components

  alias ElixirMarkdownEditor.Components.FileEditor

  @editor_path FileEditor.editor_model_path()

  @impl true
  def mount(_params, _session, socket) do
    store = ElixirMarkdownEditor.file_store()
    scope = %{root: "/path/to/files"}
    {:ok, files} = store.list_files(scope)

    {:ok,
     socket
     |> assign(:scope, scope)
     |> assign(:files, files)
     |> assign(:selected_path, nil)
     |> assign(:selected_content, "")
     |> assign(:expanded_dirs, MapSet.new())
     |> assign(:dirty, false)
     |> assign(:_editor_mounted, false)
     |> assign(:_ignore_next_change, false)}
  end

  @impl true
  def handle_event("eme:select_file", %{"path" => path}, socket) do
    store = ElixirMarkdownEditor.file_store()

    case store.read_file(socket.assigns.scope, path) do
      {:ok, content} ->
        language = ElixirMarkdownEditor.Language.detect(path)

        socket =
          socket
          |> assign(:selected_path, path)
          |> assign(:selected_content, content)
          |> assign(:dirty, false)

        socket =
          if socket.assigns._editor_mounted do
            socket
            |> assign(:_ignore_next_change, true)
            |> LiveMonacoEditor.set_value(content, to: @editor_path)
            |> LiveMonacoEditor.change_language(language, to: @editor_path)
          else
            assign(socket, :_editor_mounted, true)
          end

        {:noreply, socket}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, "Failed to open file")}
    end
  end

  def handle_event("eme:toggle_dir", %{"path" => path}, socket) do
    expanded = socket.assigns.expanded_dirs

    updated =
      if MapSet.member?(expanded, path),
        do: MapSet.delete(expanded, path),
        else: MapSet.put(expanded, path)

    {:noreply, assign(socket, :expanded_dirs, updated)}
  end

  def handle_event("eme:file_changed", %{"value" => content}, socket) do
    if socket.assigns._ignore_next_change do
      {:noreply,
       socket
       |> assign(:selected_content, content)
       |> assign(:_ignore_next_change, false)}
    else
      {:noreply,
       socket
       |> assign(:selected_content, content)
       |> assign(:dirty, true)}
    end
  end

  def handle_event("eme:file_saved", %{"path" => _path}, socket) do
    store = ElixirMarkdownEditor.file_store()
    path = socket.assigns.selected_path

    case store.write_file(socket.assigns.scope, path, socket.assigns.selected_content) do
      :ok -> {:noreply, assign(socket, :dirty, false)}
      {:error, reason} -> {:noreply, put_flash(socket, :error, "Save failed: #{inspect(reason)}")}
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.file_browser
      id="my-files"
      files={@files}
      scope={@scope}
      selected_path={@selected_path}
      selected_content={@selected_content}
      expanded_dirs={@expanded_dirs}
      dirty={@dirty}
      on_save="eme:file_saved"
    />
    """
  end
end

Important: The _ignore_next_change flag prevents LiveMonacoEditor.set_value from triggering a false dirty state. When you programmatically set editor content, Monaco fires a change event — the flag ensures it's ignored.

Individual components

You can also use the tree and editor separately:

<%!-- Just the file tree --%>
<.file_tree
  id="my-tree"
  files={@files}
  selected_path={@selected_path}
  expanded_dirs={@expanded_dirs}
/>

<%!-- Just the editor --%>
<.file_editor
  id="my-editor"
  path={@selected_path}
  value={@selected_content}
  dirty={@dirty}
  on_save="save_file"
/>

Components

Component Description
file_browser Integrated side-by-side tree + editor
file_tree Standalone collapsible file tree
file_editor Monaco editor wrapper with language detection

Events

Event Payload Description
eme:select_file%{"path" => path} File clicked in tree
eme:toggle_dir%{"path" => path} Directory expanded/collapsed
eme:file_changed%{"value" => content} Editor content changed
eme:file_saved%{"path" => path} Save button clicked

All event names are configurable via component attributes.

Language detection

ElixirMarkdownEditor.Language.detect/1 maps file extensions to Monaco language IDs. Supports 60+ file types including Elixir, JavaScript, TypeScript, Python, Ruby, Rust, Go, and more. Falls back to "plaintext" for unknown extensions.

License

MIT License. See LICENSE for details.