code/editor.ex
Editor
A code editor with file tabs, line numbers, a current-line highlight and a caret, coloured in ink tones.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.code_editor animated />
</div>
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
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
/* 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));
}