lib/live_monaco_editor.ex

defmodule LiveMonacoEditor do
  @external_resource "README.md"

  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC -->")
             |> Enum.fetch!(1)

  use Phoenix.Component
  import Phoenix.LiveView, only: [push_event: 3]
  alias Phoenix.LiveView.Socket

  @default_path "file"

  @default_opts %{
    "theme" => "default",
    "fontFamily" => "JetBrains Mono, monospace",
    "language" => "markdown",
    "fontSize" => 14,
    "automaticLayout" => true,
    "minimap" => %{
      "enabled" => false
    },
    "scrollBeyondLastLine" => false,
    "occurrencesHighlight" => false,
    "renderLineHighlight" => "none",
    "tabSize" => 2,
    "formatOnType" => true,
    "formatOnPaste" => true,
    "tabCompletion" => "on",
    "suggestSelection" => "first"
  }

  @doc """
  Renders a monaco editor [model](https://microsoft.github.io/monaco-editor/docs.html#functions/editor.createModel.html).


  ## Examples

  Render a simple editor using default options:

      <LiveMonacoEditor.code_editor value="# My Code Editor" />

  Or merge with custom options:

      <LiveMonacoEditor.code_editor
        opts={
          Map.merge(
            LiveMonacoEditor.default_opts(),
            %{"wordWrap" => "on"}
          )
        }
      />

  """
  attr :path, :string,
    default: @default_path,
    doc: "file identifier, pass unique names to render multiple editors"

  attr :value, :string, default: "", doc: "initial content"

  attr :opts, :map,
    default: @default_opts,
    doc: """
    options for the monaco editor instance

    ## Example

        %{
          "language" => "markdown",
          "fontSize" => 12,
          "wordWrap" => "on"
        }

    See all available options at https://microsoft.github.io/monaco-editor/docs.html#interfaces/editor.IStandaloneEditorConstructionOptions.html
    """

  attr :style, :string, default: "min-height: 100px; width: 100%;"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the editor container element"

  def code_editor(assigns) do
    opts =
      assigns
      |> Map.get(:opts, %{})
      |> Jason.encode!()

    assigns = assign(assigns, :opts, opts)

    ~H"""
    <div
      id={"lme-code-#{random_id()}"}
      style={@style}
      phx-update="ignore"
      phx-hook="CodeEditorHook"
      data-path={@path}
      data-value={@value}
      data-opts={@opts}
      {@rest}
    >
    </div>
    """
  end

  @doc """
  The default Monaco Editor opts passed to `<.code_editor>`
  """
  def default_opts, do: @default_opts

  # https://github.com/phoenixframework/phoenix_live_view/blob/c3c21d6de55315adea04e28f7a461a91e46497bb/lib/phoenix_live_view/utils.ex#L176-L183
  defp random_encoded_bytes do
    binary = <<
      System.system_time(:nanosecond)::64,
      :erlang.phash2({node(), self()})::16,
      :erlang.unique_integer()::16
    >>

    Base.url_encode64(binary)
  end

  defp random_id do
    String.replace(random_encoded_bytes(), ["/", "+"], "-")
  end

  @doc """
  Change the editor's language.

  ## Examples

      LiveMonacoEditor.change_language(socket, "markdown", to: "my_file.md")

  ## Options

    * `:to` - the editor's `path` name that will get the language changed. Defaults to "#{@default_path}".

  See https://microsoft.github.io/monaco-editor/docs.html#functions/editor.setModelLanguage.html for more info.
  """
  @spec change_language(Socket.t(), String.t(), keyword()) :: Socket.t()
  def change_language(socket, mime_type_or_language_id, opts \\ [])
      when is_binary(mime_type_or_language_id) do
    to = Keyword.get(opts, :to, @default_path)

    push_event(socket, "lme:change_language:#{to}", %{
      "mimeTypeOrLanguageId" => mime_type_or_language_id
    })
  end

  @doc """
  Change the editor's `value` (content).

  ## Examples

      LiveMonacoEditor.set_value(socket, "Enum.all?([1, 2, 3])", to: "my_script.exs")

  ## Options

    * `:to` - the editor's `path` name that will get the value updated. Defaults to "#{@default_path}".

  See https://microsoft.github.io/monaco-editor/docs.html#interfaces/editor.IStandaloneCodeEditor.html#setValue for more info.
  """
  @spec set_value(Socket.t(), String.t(), keyword()) :: Socket.t()
  def set_value(socket, value, opts \\ []) when is_binary(value) do
    to = Keyword.get(opts, :to, @default_path)
    push_event(socket, "lme:set_value:#{to}", %{"value" => value})
  end
end