scroll and highlight selected line
added line numbers to source view, fixed css selectors
This commit is contained in:
parent
790d2f0e1d
commit
a9e746d520
@ -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;
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|
||||
{: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
|
||||
|
||||
97
app/test/firehose_web/live/source_viewer_test.exs
Normal file
97
app/test/firehose_web/live/source_viewer_test.exs
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user