empowered.house ↗

code/editor.ex

Editor

A code editor with file tabs, line numbers, a current-line highlight and a caret, coloured in ink tones.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.code_editor animated />
</div>
default
isometric
a workflow file · second tab open
hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
tabs list ["approval.ex", "order.ex", "router.ex"] Up to four file names, up to 14 characters each.
active integer 0 Index of the open tab.
lines list ["defmodule Orders.Approval do", " # Large orders wait for a manager.", " def route(%Order{total: total} = order) do", " if total > 5_000 do", " Agents.ask(order, \"manager\")", " else", ...] Up to fourteen lines of code as strings, up to 58 characters each.
current integer 5 Number of the highlighted line, counted from 1. The caret sits at its end.
language string "Elixir"
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/code/editor.ex
editor.ex
defmodule EHVisuals.Code.Editor do
  @moduledoc """
  A code editor: file tabs, numbered lines, the current line highlighted and
  a caret at its end. Syntax colouring uses ink tones, with lime for strings
  only.
  """

  use EHVisuals.Composition,
    name: "code/editor",
    title: "Editor",
    description:
      "A code editor with file tabs, line numbers, a current-line highlight and a caret, coloured in ink tones."

  @lines [
    "defmodule Orders.Approval do",
    "  # Large orders wait for a manager.",
    "  def route(%Order{total: total} = order) do",
    "    if total > 5_000 do",
    "      Agents.ask(order, \"manager\")",
    "    else",
    "      Erp.post(order, \"approved\")",
    "    end",
    "  end",
    "end"
  ]

  # Tone of each token kind, in the order of the groups in token_pattern/0.
  @tones ["ehv-t-3", "ehv-t-lime", nil, "ehv-t-strong", nil, nil, "ehv-t-2", "ehv-t-3"]

  # Width of one character at font size 10.
  @char 6.0

  attr(:tabs, :list,
    default: ~w(approval.ex order.ex router.ex),
    doc: "Up to four file names, up to 14 characters each."
  )

  attr(:active, :integer, default: 0, doc: "Index of the open tab.")

  attr(:lines, :list,
    default: @lines,
    doc: "Up to fourteen lines of code as strings, up to 58 characters each."
  )

  attr(:current, :integer,
    default: 5,
    doc: "Number of the highlighted line, counted from 1. The caret sits at its end."
  )

  attr(:language, :string, default: "Elixir")

  attr(:label, :string,
    default: "A code editor with three tabs and ten lines of Elixir, the fifth line highlighted"
  )

  frame_attrs()

  @spec code_editor(map()) :: Phoenix.LiveView.Rendered.t()
  def code_editor(assigns) do
    lines = Enum.take(assigns.lines, 14)
    count = length(lines)
    current = assigns.current |> max(1) |> min(max(count, 1))

    rows =
      for {line, index} <- Enum.with_index(lines) do
        %{
          number: index + 1,
          y: 60 + index * 19,
          current: index + 1 == current,
          tokens: tokens(line),
          step: min(index + 2, 24)
        }
      end

    {tabs, _x} =
      assigns.tabs
      |> Enum.take(4)
      |> Enum.with_index()
      |> Enum.map_reduce(12.0, fn {name, index}, x ->
        width = Float.round(20 + String.length(name) * 5.4, 1)
        {%{name: name, x: x, w: width, on: index == assigns.active}, x + width + 4}
      end)

    column = lines |> Enum.at(current - 1, "") |> String.length()
    height = 34 + 14 + count * 19 + 6 + 24

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:tab_items, tabs)
      |> assign(:caret_x, Float.round(50 + column * @char + 1.5, 1))
      |> assign(:caret_y, 60 + (current - 1) * 19)
      |> assign(:caret_step, min(count + 3, 24))
      |> assign(:position, "Ln #{current}, Col #{column + 1}")
      |> assign(:height, height)

    ~H"""
    <.frame name="code/editor" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 420 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="419" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <g :for={tab <- @tab_items}>
              <rect
                :if={tab.on}
                class="ehv-fill-surface-3"
                x={tab.x}
                y="7"
                width={tab.w}
                height="21"
                rx="6"
              />
              <text
                class={["ehv-code", not tab.on && "ehv-t-3"]}
                x={tab.x + 10}
                y="21"
                font-size="9"
              >
                {tab.name}
              </text>
            </g>
            <line class="ehv-line-soft" x1="0.5" x2="419.5" y1="34.5" y2="34.5" />
            <line class="ehv-line-faint" x1="0.5" x2="419.5" y1={@height - 24.5} y2={@height - 24.5} />
            <text class="ehv-mono" x="16" y={@height - 9.5} font-size="7.5">
              {String.upcase(@language)}
            </text>
            <text class="ehv-mono" x="404" y={@height - 9.5} font-size="7.5" text-anchor="end">
              {String.upcase(@position)}
            </text>
          </g>
          <g :for={row <- @rows} class={"ehv-a-fade ehv-i-#{row.step}"}>
            <rect
              :if={row.current}
              class="ehv-code-editor__current"
              x="1"
              y={row.y - 13}
              width="418"
              height="19"
            />
            <text
              class={["ehv-code", not row.current && "ehv-t-3"]}
              x="32"
              y={row.y}
              font-size="8.5"
              text-anchor="end"
            >
              {row.number}
            </text>
            <text
              :for={token <- row.tokens}
              class={["ehv-code", token.tone]}
              x={token.x}
              y={row.y}
              font-size="10"
            >
              {token.text}
            </text>
          </g>
          <rect
            class={"ehv-code-editor__caret ehv-i-#{@caret_step}"}
            x={@caret_x}
            y={@caret_y - 10.5}
            width="1.8"
            height="14"
            rx="0.9"
          />
        </g>
      </svg>
    </.frame>
    """
  end

  # Splits a line into tokens, each with its column. SVG text drops runs of
  # spaces, so every token is placed by its own x.
  defp tokens(line) do
    token_pattern()
    |> Regex.scan(line, return: :index)
    |> Enum.flat_map(fn [{start, length} | groups] ->
      case Enum.find_index(groups, fn {position, _length} -> position >= 0 end) do
        2 ->
          []

        kind ->
          [
            %{
              text: binary_part(line, start, length),
              x: Float.round(50 + start * @char, 1),
              tone: Enum.at(@tones, kind || 7)
            }
          ]
      end
    end)
  end

  # Groups: comment, string, space, keyword, atom or key, module or number,
  # name, any other character.
  defp token_pattern do
    ~r/(#.*)|("(?:[^"\\]|\\.)*")|(\s+)|(\b(?:defmodule|defp|def|do|end|if|else|fn|case|cond|when|with|alias|import|use|nil|true|false)\b)|(:[a-z_]\w*[?!]?|[a-z_]\w*[?!]?:(?!:))|([A-Z]\w*|\d[\d_.]*)|(@?[a-z_]\w*[?!]?)|(.)/
  end
end
assets/css/code/editor.css
editor.css
/* code/editor */
.ehv-code-editor { --ehv-max-w: 460px; --ehv-step: 70ms; --ehv-dur: 400ms; }

.ehv-code-editor__current { fill: var(--ehv-surface-2); }

/* The caret takes the line colour, so it stays visible on the light surface. */
.ehv-code-editor__caret { fill: var(--ehv-lime); }

@keyframes ehv-code-editor-blink { 50% { opacity: 0; } }
.ehv--animated .ehv-code-editor__caret {
  animation: ehv-fade var(--ehv-dur) var(--ehv-ease) backwards, ehv-code-editor-blink 1.1s steps(1) infinite;
  animation-delay: calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step)), calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step) + var(--ehv-dur));
}