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

View File

@ -4,7 +4,7 @@
* Attached to the `#source-viewer` div via `phx-hook="SourceViewer"`.
* Reads `data-highlighted-line` to find the target line element and
* 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"
@ -20,10 +20,33 @@ export const SourceViewer = {
},
applySyntaxHighlighting() {
const codeEl = this.el.querySelector("code")
if (codeEl && !codeEl.querySelector(".hljs")) {
hljs.highlightElement(codeEl)
}
const container = this.el.querySelector(".sv-lines")
if (!container || container.querySelector(".hljs-on")) return;
// 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() {
@ -35,4 +58,4 @@ export const SourceViewer = {
}
}
}
}
}

View File

@ -26,7 +26,7 @@ defmodule FirehoseWeb.MicroprintsLive do
|> assign(:page_title, "Microprints")
|> assign(:microprints, [])
|> assign(:expanded_path, nil)
|> assign(:source_content, nil)
|> assign(:source_lines, nil)
|> assign(:highlighted_path, nil)
|> assign(:highlighted_line, nil)
|> put_flash(:error, "Error loading microprints: #{inspect(e)}")}
@ -80,8 +80,8 @@ defmodule FirehoseWeb.MicroprintsLive do
highlighted_line={@highlighted_line}
/>
<%= if @expanded_path == path and @source_content do %> <.source_viewer
source_content={@source_content}
<%= if @expanded_path == path and @source_lines do %> <.source_viewer
source_lines={@source_lines}
highlighted_line={@highlighted_line}
language="elixir"
file_path={path}
@ -120,7 +120,7 @@ defmodule FirehoseWeb.MicroprintsLive do
|> assign(:highlighted_path, path)
|> assign(:highlighted_line, highlighted)
|> assign(:expanded_path, expanded)
|> assign(:source_content, nil)
|> assign(:source_lines, nil)
{:noreply, push_patch(socket, to: build_params(socket))}
end
@ -133,7 +133,7 @@ defmodule FirehoseWeb.MicroprintsLive do
_ -> path
end
source_content =
source_lines =
case expanded do
nil -> nil
^path ->
@ -146,7 +146,7 @@ defmodule FirehoseWeb.MicroprintsLive do
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))}
end
@ -158,7 +158,7 @@ defmodule FirehoseWeb.MicroprintsLive do
highlighted_path = params["highlighted"]
highlighted_line = if params["line"], do: String.to_integer(params["line"]), else: nil
source_content =
source_lines =
if expanded_path do
item = Enum.find(socket.assigns.microprints, &(&1.path == expanded_path))
@ -170,7 +170,7 @@ defmodule FirehoseWeb.MicroprintsLive do
socket
|> assign(:expanded_path, expanded_path)
|> assign(:source_content, source_content)
|> assign(:source_lines, source_lines)
|> assign(:highlighted_path, highlighted_path)
|> assign(:highlighted_line, highlighted_line)
end
@ -325,13 +325,8 @@ defmodule FirehoseWeb.MicroprintsLive do
defp read_source(abs_path) do
case File.read(abs_path) do
{:ok, content} ->
content
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
{:error, _} ->
nil
{:ok, content} -> String.split(content, "\n")
{:error, _} -> nil
end
end
@ -353,7 +348,17 @@ defmodule FirehoseWeb.MicroprintsLive do
data-language={@language}
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>
"""
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