scroll and highlight selected line

added line numbers to source view, fixed css selectors
This commit is contained in:
Firehose Bot 2026-05-18 21:31:01 +01:00
parent 790d2f0e1d
commit a9e746d520
4 changed files with 217 additions and 93 deletions

View File

@ -286,124 +286,123 @@ body { font-family: 'Source Sans 3', sans-serif; }
} }
/* highlight.js — Atom One Dark (dark theme) */ /* highlight.js — Atom One Dark (dark theme) */
.source-viewer code.hljs { .source-viewer .hljs {
color: #abb2bf; color: #abb2bf;
background: transparent; background: transparent;
padding: 0;
} }
.source-viewer code.hljs .hljs-comment, .source-viewer .hljs .hljs-comment,
.source-viewer code.hljs .hljs-quote { .source-viewer .hljs .hljs-quote {
color: #5c6370; color: #5c6370;
font-style: italic; font-style: italic;
} }
.source-viewer code.hljs .hljs-doctag, .source-viewer .hljs .hljs-doctag,
.source-viewer code.hljs .hljs-keyword, .source-viewer .hljs .hljs-keyword,
.source-viewer code.hljs .hljs-formula { .source-viewer .hljs .hljs-formula {
color: #c678dd; color: #c678dd;
} }
.source-viewer code.hljs .hljs-section, .source-viewer .hljs .hljs-section,
.source-viewer code.hljs .hljs-name, .source-viewer .hljs .hljs-name,
.source-viewer code.hljs .hljs-selector-tag, .source-viewer .hljs .hljs-selector-tag,
.source-viewer code.hljs .hljs-deletion, .source-viewer .hljs .hljs-deletion,
.source-viewer code.hljs .hljs-subst { .source-viewer .hljs .hljs-subst {
color: #e06c75; color: #e06c75;
} }
.source-viewer code.hljs .hljs-literal { .source-viewer .hljs .hljs-literal {
color: #56b6c2; color: #56b6c2;
} }
.source-viewer code.hljs .hljs-string, .source-viewer .hljs .hljs-string,
.source-viewer code.hljs .hljs-regexp, .source-viewer .hljs .hljs-regexp,
.source-viewer code.hljs .hljs-addition, .source-viewer .hljs .hljs-addition,
.source-viewer code.hljs .hljs-attribute, .source-viewer .hljs .hljs-attribute,
.source-viewer code.hljs .hljs-meta .hljs-string { .source-viewer .hljs .hljs-meta .hljs-string {
color: #98c379; color: #98c379;
} }
.source-viewer code.hljs .hljs-attr, .source-viewer .hljs .hljs-attr,
.source-viewer code.hljs .hljs-variable, .source-viewer .hljs .hljs-variable,
.source-viewer code.hljs .hljs-template-variable, .source-viewer .hljs .hljs-template-variable,
.source-viewer code.hljs .hljs-type, .source-viewer .hljs .hljs-type,
.source-viewer code.hljs .hljs-selector-class, .source-viewer .hljs .hljs-selector-class,
.source-viewer code.hljs .hljs-selector-attr, .source-viewer .hljs .hljs-selector-attr,
.source-viewer code.hljs .hljs-selector-pseudo, .source-viewer .hljs .hljs-selector-pseudo,
.source-viewer code.hljs .hljs-number { .source-viewer .hljs .hljs-number {
color: #d19a66; color: #d19a66;
} }
.source-viewer code.hljs .hljs-symbol, .source-viewer .hljs .hljs-symbol,
.source-viewer code.hljs .hljs-link, .source-viewer .hljs .hljs-link,
.source-viewer code.hljs .hljs-meta, .source-viewer .hljs .hljs-meta,
.source-viewer code.hljs .hljs-selector-id, .source-viewer .hljs .hljs-selector-id,
.source-viewer code.hljs .hljs-title { .source-viewer .hljs .hljs-title {
color: #61aeee; color: #61aeee;
} }
.source-viewer code.hljs .hljs-built_in, .source-viewer .hljs .hljs-built_in,
.source-viewer code.hljs .hljs-title.class_, .source-viewer .hljs .hljs-title.class_,
.source-viewer code.hljs .hljs-class .hljs-title { .source-viewer .hljs .hljs-class .hljs-title {
color: #e6c07b; color: #e6c07b;
} }
.source-viewer code.hljs .hljs-emphasis { .source-viewer .hljs .hljs-emphasis {
font-style: italic; font-style: italic;
} }
.source-viewer code.hljs .hljs-strong { .source-viewer .hljs .hljs-strong {
font-weight: bold; font-weight: bold;
} }
/* highlight.js — Atom One Light (light theme) */ /* highlight.js — Atom One Light (light theme) */
[data-theme="light"] .source-viewer code.hljs { [data-theme="light"] .source-viewer .hljs {
color: #383a42; color: #383a42;
background: transparent; background: transparent;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-comment, [data-theme="light"] .source-viewer .hljs .hljs-comment,
[data-theme="light"] .source-viewer code.hljs .hljs-quote { [data-theme="light"] .source-viewer .hljs .hljs-quote {
color: #a0a1a7; color: #a0a1a7;
font-style: italic; font-style: italic;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-doctag, [data-theme="light"] .source-viewer .hljs .hljs-doctag,
[data-theme="light"] .source-viewer code.hljs .hljs-keyword, [data-theme="light"] .source-viewer .hljs .hljs-keyword,
[data-theme="light"] .source-viewer code.hljs .hljs-formula { [data-theme="light"] .source-viewer .hljs .hljs-formula {
color: #a626a4; color: #a626a4;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-section, [data-theme="light"] .source-viewer .hljs .hljs-section,
[data-theme="light"] .source-viewer code.hljs .hljs-name, [data-theme="light"] .source-viewer .hljs .hljs-name,
[data-theme="light"] .source-viewer code.hljs .hljs-selector-tag, [data-theme="light"] .source-viewer .hljs .hljs-selector-tag,
[data-theme="light"] .source-viewer code.hljs .hljs-deletion, [data-theme="light"] .source-viewer .hljs .hljs-deletion,
[data-theme="light"] .source-viewer code.hljs .hljs-subst { [data-theme="light"] .source-viewer .hljs .hljs-subst {
color: #e45649; color: #e45649;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-literal { [data-theme="light"] .source-viewer .hljs .hljs-literal {
color: #0184bc; color: #0184bc;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-string, [data-theme="light"] .source-viewer .hljs .hljs-string,
[data-theme="light"] .source-viewer code.hljs .hljs-regexp, [data-theme="light"] .source-viewer .hljs .hljs-regexp,
[data-theme="light"] .source-viewer code.hljs .hljs-addition, [data-theme="light"] .source-viewer .hljs .hljs-addition,
[data-theme="light"] .source-viewer code.hljs .hljs-attribute, [data-theme="light"] .source-viewer .hljs .hljs-attribute,
[data-theme="light"] .source-viewer code.hljs .hljs-meta .hljs-string { [data-theme="light"] .source-viewer .hljs .hljs-meta .hljs-string {
color: #50a14f; color: #50a14f;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-attr, [data-theme="light"] .source-viewer .hljs .hljs-attr,
[data-theme="light"] .source-viewer code.hljs .hljs-variable, [data-theme="light"] .source-viewer .hljs .hljs-variable,
[data-theme="light"] .source-viewer code.hljs .hljs-template-variable, [data-theme="light"] .source-viewer .hljs .hljs-template-variable,
[data-theme="light"] .source-viewer code.hljs .hljs-type, [data-theme="light"] .source-viewer .hljs .hljs-type,
[data-theme="light"] .source-viewer code.hljs .hljs-selector-class, [data-theme="light"] .source-viewer .hljs .hljs-selector-class,
[data-theme="light"] .source-viewer code.hljs .hljs-selector-attr, [data-theme="light"] .source-viewer .hljs .hljs-selector-attr,
[data-theme="light"] .source-viewer code.hljs .hljs-selector-pseudo, [data-theme="light"] .source-viewer .hljs .hljs-selector-pseudo,
[data-theme="light"] .source-viewer code.hljs .hljs-number { [data-theme="light"] .source-viewer .hljs .hljs-number {
color: #986801; color: #986801;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-symbol, [data-theme="light"] .source-viewer .hljs .hljs-symbol,
[data-theme="light"] .source-viewer code.hljs .hljs-link, [data-theme="light"] .source-viewer .hljs .hljs-link,
[data-theme="light"] .source-viewer code.hljs .hljs-meta, [data-theme="light"] .source-viewer .hljs .hljs-meta,
[data-theme="light"] .source-viewer code.hljs .hljs-selector-id, [data-theme="light"] .source-viewer .hljs .hljs-selector-id,
[data-theme="light"] .source-viewer code.hljs .hljs-title { [data-theme="light"] .source-viewer .hljs .hljs-title {
color: #4078f2; color: #4078f2;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-built_in, [data-theme="light"] .source-viewer .hljs .hljs-built_in,
[data-theme="light"] .source-viewer code.hljs .hljs-title.class_, [data-theme="light"] .source-viewer .hljs .hljs-title.class_,
[data-theme="light"] .source-viewer code.hljs .hljs-class .hljs-title { [data-theme="light"] .source-viewer .hljs .hljs-class .hljs-title {
color: #c18401; color: #c18401;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-emphasis { [data-theme="light"] .source-viewer .hljs .hljs-emphasis {
font-style: italic; font-style: italic;
} }
[data-theme="light"] .source-viewer code.hljs .hljs-strong { [data-theme="light"] .source-viewer .hljs .hljs-strong {
font-weight: bold; font-weight: bold;
} }

View File

@ -4,7 +4,7 @@
* Attached to the `#source-viewer` div via `phx-hook="SourceViewer"`. * Attached to the `#source-viewer` div via `phx-hook="SourceViewer"`.
* Reads `data-highlighted-line` to find the target line element and * Reads `data-highlighted-line` to find the target line element and
* scrolls it into view with a smooth animation. * scrolls it into view with a smooth animation.
* Also applies highlight.js syntax highlighting to code blocks. * Applies highlight.js syntax highlighting to individual line content elements.
*/ */
import hljs from "highlight.js/lib/core" import hljs from "highlight.js/lib/core"
@ -20,10 +20,33 @@ export const SourceViewer = {
}, },
applySyntaxHighlighting() { applySyntaxHighlighting() {
const codeEl = this.el.querySelector("code") const container = this.el.querySelector(".sv-lines")
if (codeEl && !codeEl.querySelector(".hljs")) { if (!container || container.querySelector(".hljs-on")) return;
hljs.highlightElement(codeEl)
// Collect the raw text from all line content spans
const lineContentEls = container.querySelectorAll(".sv-line-content")
if (lineContentEls.length === 0) return;
const rawText = Array.from(lineContentEls).map(el => el.textContent).join("\n")
// Create a temp element for highlight.js to process
const temp = document.createElement("code")
temp.textContent = rawText
hljs.highlightElement(temp)
// Split the highlighted HTML back into lines
// highlight.js innerHTML uses actual newlines between lines of code
const highlightedLines = temp.innerHTML.split("\n")
// Apply each highlighted line to the corresponding .sv-line-content
lineContentEls.forEach((el, i) => {
if (highlightedLines[i]) {
el.innerHTML = highlightedLines[i]
} }
})
// Mark as highlighted so we don't re-apply on subsequent updates
container.classList.add("hljs", "hljs-on")
}, },
scrollToHighlighted() { scrollToHighlighted() {

View File

@ -26,7 +26,7 @@ defmodule FirehoseWeb.MicroprintsLive do
|> assign(:page_title, "Microprints") |> assign(:page_title, "Microprints")
|> assign(:microprints, []) |> assign(:microprints, [])
|> assign(:expanded_path, nil) |> assign(:expanded_path, nil)
|> assign(:source_content, nil) |> assign(:source_lines, nil)
|> assign(:highlighted_path, nil) |> assign(:highlighted_path, nil)
|> assign(:highlighted_line, nil) |> assign(:highlighted_line, nil)
|> put_flash(:error, "Error loading microprints: #{inspect(e)}")} |> put_flash(:error, "Error loading microprints: #{inspect(e)}")}
@ -80,8 +80,8 @@ defmodule FirehoseWeb.MicroprintsLive do
highlighted_line={@highlighted_line} highlighted_line={@highlighted_line}
/> />
<%= if @expanded_path == path and @source_content do %> <.source_viewer <%= if @expanded_path == path and @source_lines do %> <.source_viewer
source_content={@source_content} source_lines={@source_lines}
highlighted_line={@highlighted_line} highlighted_line={@highlighted_line}
language="elixir" language="elixir"
file_path={path} file_path={path}
@ -120,7 +120,7 @@ defmodule FirehoseWeb.MicroprintsLive do
|> assign(:highlighted_path, path) |> assign(:highlighted_path, path)
|> assign(:highlighted_line, highlighted) |> assign(:highlighted_line, highlighted)
|> assign(:expanded_path, expanded) |> assign(:expanded_path, expanded)
|> assign(:source_content, nil) |> assign(:source_lines, nil)
{:noreply, push_patch(socket, to: build_params(socket))} {:noreply, push_patch(socket, to: build_params(socket))}
end end
@ -133,7 +133,7 @@ defmodule FirehoseWeb.MicroprintsLive do
_ -> path _ -> path
end end
source_content = source_lines =
case expanded do case expanded do
nil -> nil nil -> nil
^path -> ^path ->
@ -146,7 +146,7 @@ defmodule FirehoseWeb.MicroprintsLive do
end end
end end
socket = socket |> assign(:expanded_path, expanded) |> assign(:source_content, source_content) socket = socket |> assign(:expanded_path, expanded) |> assign(:source_lines, source_lines)
{:noreply, push_patch(socket, to: build_params(socket))} {:noreply, push_patch(socket, to: build_params(socket))}
end end
@ -158,7 +158,7 @@ defmodule FirehoseWeb.MicroprintsLive do
highlighted_path = params["highlighted"] highlighted_path = params["highlighted"]
highlighted_line = if params["line"], do: String.to_integer(params["line"]), else: nil highlighted_line = if params["line"], do: String.to_integer(params["line"]), else: nil
source_content = source_lines =
if expanded_path do if expanded_path do
item = Enum.find(socket.assigns.microprints, &(&1.path == expanded_path)) item = Enum.find(socket.assigns.microprints, &(&1.path == expanded_path))
@ -170,7 +170,7 @@ defmodule FirehoseWeb.MicroprintsLive do
socket socket
|> assign(:expanded_path, expanded_path) |> assign(:expanded_path, expanded_path)
|> assign(:source_content, source_content) |> assign(:source_lines, source_lines)
|> assign(:highlighted_path, highlighted_path) |> assign(:highlighted_path, highlighted_path)
|> assign(:highlighted_line, highlighted_line) |> assign(:highlighted_line, highlighted_line)
end end
@ -325,13 +325,8 @@ defmodule FirehoseWeb.MicroprintsLive do
defp read_source(abs_path) do defp read_source(abs_path) do
case File.read(abs_path) do case File.read(abs_path) do
{:ok, content} -> {:ok, content} -> String.split(content, "\n")
content {:error, _} -> nil
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
{:error, _} ->
nil
end end
end end
@ -353,7 +348,17 @@ defmodule FirehoseWeb.MicroprintsLive do
data-language={@language} data-language={@language}
style="background: var(--sv-bg); color: var(--sv-text);" style="background: var(--sv-bg); color: var(--sv-text);"
> >
<pre id={@viewer_id <> "-pre"} phx-update="ignore" class="m-0 p-2"><code class={"language-#{@language || "plaintext"}"}><%= @source_content %></code></pre> <div class="sv-lines p-2">
<%= for {line, num} <- Enum.with_index(@source_lines || [], 1) do %>
<div
id={"line-#{num}"}
class={"sv-line" <> if @highlighted_line == num, do: " sv-line-highlighted", else: ""}
>
<span class="sv-line-number"><%= num %></span>
<span class="sv-line-content"><%= line %></span>
</div>
<% end %>
</div>
</div> </div>
""" """
end end

View File

@ -0,0 +1,97 @@
defmodule FirehoseWeb.SourceViewerTest do
use FirehoseWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias FirehoseWeb.MicroprintsLive
defp expand_file(view, path) do
view
|> element("button[phx-value-path=\"#{path}\"]", "Expand")
|> render_click()
end
defp click_microprint_rect(view, path, line) do
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
end
describe "line elements" do
test "render with unique ids when file is expanded", %{conn: conn} do
files = MicroprintsLive.scan_source_files()
file = List.first(files)
{:ok, view, _html} = live(conn, ~p"/microprints")
expand_file(view, file)
assert has_element?(view, "#line-1"),
"Line 1 should exist in the source viewer"
assert has_element?(view, "#line-2"),
"Line 2 should exist in the source viewer"
assert has_element?(view, "#line-3"),
"Line 3 should exist in the source viewer"
end
end
describe "highlight class" do
test "is added to line on microprint click", %{conn: conn} do
files = MicroprintsLive.scan_source_files()
file = List.first(files)
{:ok, view, _html} = live(conn, ~p"/microprints")
expand_file(view, file)
click_microprint_rect(view, file, 1)
assert has_element?(view, "#line-1.sv-line-highlighted"),
"Line 1 should have the highlighted class after click"
end
test "is removed on re-clicking the same line", %{conn: conn} do
files = MicroprintsLive.scan_source_files()
file = List.first(files)
{:ok, view, _html} = live(conn, ~p"/microprints")
expand_file(view, file)
click_microprint_rect(view, file, 1)
assert has_element?(view, "#line-1.sv-line-highlighted"),
"Line 1 should be highlighted after first click"
click_microprint_rect(view, file, 1)
refute has_element?(view, "#line-1.sv-line-highlighted"),
"Line 1 should no longer be highlighted after second click"
end
test "persists when expand is restored from URL params", %{conn: conn} do
files = MicroprintsLive.scan_source_files()
file = List.first(files)
{:ok, view, _html} = live(conn, ~p"/microprints?expanded=#{file}&line=1&highlighted=#{file}")
assert has_element?(view, "#line-1.sv-line-highlighted"),
"Line 1 should be highlighted when restored from URL params"
end
end
describe "different file highlighting" do
test "collapses file A and removes its source viewer when highlighting file B", %{conn: conn} do
files = MicroprintsLive.scan_source_files()
assert length(files) >= 2, "Need at least 2 files to test different-file highlighting"
[file_a, file_b | _] = files
{:ok, view, _html} = live(conn, ~p"/microprints")
expand_file(view, file_a)
click_microprint_rect(view, file_a, 1)
assert has_element?(view, "#line-1.sv-line-highlighted"),
"Line 1 in file A should be highlighted before switching"
click_microprint_rect(view, file_b, 2)
refute has_element?(view, "#line-1"),
"File A source viewer should be gone after highlighting a different file"
end
end
end