mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-09 21:26:05 +08:00
Highlight viewed section within the list (#76)
* Add fallback primary fonts * Highlight viewed section in the navbar * Fix moving cells with empty sections * Reword attribute
This commit is contained in:
parent
a2d1e2f934
commit
7fa2b44666
11 changed files with 116 additions and 21 deletions
|
|
@ -5,5 +5,6 @@ button:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Inter";
|
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||||
|
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,7 @@ and entering/escaping insert mode.
|
||||||
[data-element="cell"]:not([data-js-focused]) [data-element="actions"] {
|
[data-element="cell"]:not([data-js-focused]) [data-element="actions"] {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-element="section-list-item"][data-js-is-viewed] {
|
||||||
|
@apply text-current;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import "../css/app.css";
|
import "../css/app.css";
|
||||||
import 'remixicon/fonts/remixicon.css'
|
import "remixicon/fonts/remixicon.css";
|
||||||
|
|
||||||
import "@fontsource/inter";
|
import "@fontsource/inter";
|
||||||
import "@fontsource/inter/500.css";
|
import "@fontsource/inter/500.css";
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ const Cell = {
|
||||||
this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => {
|
this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => {
|
||||||
const { source, revision } = payload;
|
const { source, revision } = payload;
|
||||||
|
|
||||||
const editorContainer = this.el.querySelector(`[data-element="editor-container"]`);
|
const editorContainer = this.el.querySelector(
|
||||||
|
`[data-element="editor-container"]`
|
||||||
|
);
|
||||||
// Remove the content placeholder.
|
// Remove the content placeholder.
|
||||||
editorContainer.firstElementChild.remove();
|
editorContainer.firstElementChild.remove();
|
||||||
// Create an empty container for the editor to be mounted in.
|
// Create an empty container for the editor to be mounted in.
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class LiveEditor {
|
||||||
occurrencesHighlight: false,
|
occurrencesHighlight: false,
|
||||||
renderLineHighlight: "none",
|
renderLineHighlight: "none",
|
||||||
theme: "custom",
|
theme: "custom",
|
||||||
fontFamily: "JetBrains Mono"
|
fontFamily: "JetBrains Mono",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.editor.getModel().updateOptions({
|
this.editor.getModel().updateOptions({
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,16 @@ const Session = {
|
||||||
|
|
||||||
document.addEventListener("mousedown", this.handleDocumentMouseDown);
|
document.addEventListener("mousedown", this.handleDocumentMouseDown);
|
||||||
|
|
||||||
this.el.querySelector(`[data-element="section-list"]`).addEventListener("click", event => {
|
getSectionList().addEventListener("click", (event) => {
|
||||||
handleSectionListClick(this, event);
|
handleSectionListClick(this, event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateSectionListHighlight();
|
||||||
|
|
||||||
|
getNotebook().addEventListener("scroll", (event) => {
|
||||||
|
updateSectionListHighlight()
|
||||||
|
});
|
||||||
|
|
||||||
// Server events
|
// Server events
|
||||||
|
|
||||||
this.handleEvent("cell_inserted", ({ cell_id: cellId }) => {
|
this.handleEvent("cell_inserted", ({ cell_id: cellId }) => {
|
||||||
|
|
@ -178,7 +184,9 @@ function handleDocumentMouseDown(hook, event) {
|
||||||
|
|
||||||
// Depending on whether the click targets editor disable/enable insert mode
|
// Depending on whether the click targets editor disable/enable insert mode
|
||||||
if (cell) {
|
if (cell) {
|
||||||
const editorContainer = cell.querySelector(`[data-element="editor-container"]`);
|
const editorContainer = cell.querySelector(
|
||||||
|
`[data-element="editor-container"]`
|
||||||
|
);
|
||||||
const editorClicked = editorContainer.contains(event.target);
|
const editorClicked = editorContainer.contains(event.target);
|
||||||
const insertMode = editorClicked;
|
const insertMode = editorClicked;
|
||||||
if (hook.state.insertMode !== insertMode) {
|
if (hook.state.insertMode !== insertMode) {
|
||||||
|
|
@ -191,7 +199,9 @@ function handleDocumentMouseDown(hook, event) {
|
||||||
* Handles section link clicks in the section list.
|
* Handles section link clicks in the section list.
|
||||||
*/
|
*/
|
||||||
function handleSectionListClick(hook, event) {
|
function handleSectionListClick(hook, event) {
|
||||||
const sectionButton = event.target.closest(`[data-element="section-list-item"]`);
|
const sectionButton = event.target.closest(
|
||||||
|
`[data-element="section-list-item"]`
|
||||||
|
);
|
||||||
if (sectionButton) {
|
if (sectionButton) {
|
||||||
const sectionId = sectionButton.getAttribute("data-section-id");
|
const sectionId = sectionButton.getAttribute("data-section-id");
|
||||||
const section = getSectionById(sectionId);
|
const section = getSectionById(sectionId);
|
||||||
|
|
@ -199,6 +209,36 @@ function handleSectionListClick(hook, event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the main notebook area being scrolled.
|
||||||
|
*/
|
||||||
|
function updateSectionListHighlight() {
|
||||||
|
const currentListItem = document.querySelector(
|
||||||
|
`[data-element="section-list-item"][data-js-is-viewed]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentListItem) {
|
||||||
|
currentListItem.removeAttribute("data-js-is-viewed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider a section being viewed if it is within the top 35% of the screen
|
||||||
|
const viewedSection = getSections()
|
||||||
|
.reverse()
|
||||||
|
.find((section) => {
|
||||||
|
const { top } = section.getBoundingClientRect();
|
||||||
|
const scrollTop = document.documentElement.scrollTop;
|
||||||
|
return top <= scrollTop + window.innerHeight * 0.35;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (viewedSection) {
|
||||||
|
const sectionId = viewedSection.getAttribute("data-section-id");
|
||||||
|
const listItem = document.querySelector(
|
||||||
|
`[data-element="section-list-item"][data-section-id="${sectionId}"]`
|
||||||
|
);
|
||||||
|
listItem.setAttribute("data-js-is-viewed", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// User action handlers (mostly keybindings)
|
// User action handlers (mostly keybindings)
|
||||||
|
|
||||||
function deleteFocusedCell(hook) {
|
function deleteFocusedCell(hook) {
|
||||||
|
|
@ -376,7 +416,7 @@ function handleCellMoved(hook, cellId) {
|
||||||
|
|
||||||
function handleSectionInserted(hook, sectionId) {
|
function handleSectionInserted(hook, sectionId) {
|
||||||
const section = getSectionById(sectionId);
|
const section = getSectionById(sectionId);
|
||||||
const nameElement = section.querySelector("[data-section-name]");
|
const nameElement = section.querySelector(`[data-element="section-name"]`);
|
||||||
nameElement.focus({ preventScroll: true });
|
nameElement.focus({ preventScroll: true });
|
||||||
selectElementContent(nameElement);
|
selectElementContent(nameElement);
|
||||||
smoothlyScrollToElement(nameElement);
|
smoothlyScrollToElement(nameElement);
|
||||||
|
|
@ -445,6 +485,14 @@ function getSectionById(sectionId) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSectionList() {
|
||||||
|
return document.querySelector(`[data-element="section-list"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotebook() {
|
||||||
|
return document.querySelector(`[data-element="notebook"]`);
|
||||||
|
}
|
||||||
|
|
||||||
function cancelEvent(event) {
|
function cancelEvent(event) {
|
||||||
// Cancel any default browser behavior.
|
// Cancel any default browser behavior.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ describe("PubSub", () => {
|
||||||
const callback1 = jest.fn();
|
const callback1 = jest.fn();
|
||||||
const callback2 = jest.fn();
|
const callback2 = jest.fn();
|
||||||
|
|
||||||
pubsub.subscribe('topic1', callback1);
|
pubsub.subscribe("topic1", callback1);
|
||||||
pubsub.subscribe('topic2', callback2);
|
pubsub.subscribe("topic2", callback2);
|
||||||
pubsub.broadcast('topic1', { data: 1 });
|
pubsub.broadcast("topic1", { data: 1 });
|
||||||
|
|
||||||
expect(callback1).toHaveBeenCalledWith({ data: 1 });
|
expect(callback1).toHaveBeenCalledWith({ data: 1 });
|
||||||
expect(callback2).not.toHaveBeenCalled();
|
expect(callback2).not.toHaveBeenCalled();
|
||||||
|
|
@ -18,9 +18,9 @@ describe("PubSub", () => {
|
||||||
const pubsub = new PubSub();
|
const pubsub = new PubSub();
|
||||||
const callback1 = jest.fn();
|
const callback1 = jest.fn();
|
||||||
|
|
||||||
pubsub.subscribe('topic1', callback1);
|
pubsub.subscribe("topic1", callback1);
|
||||||
pubsub.unsubscribe('topic1', callback1);
|
pubsub.unsubscribe("topic1", callback1);
|
||||||
pubsub.broadcast('topic1', {});
|
pubsub.broadcast("topic1", {});
|
||||||
|
|
||||||
expect(callback1).not.toHaveBeenCalled();
|
expect(callback1).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -191,10 +191,7 @@ defmodule Livebook.Notebook do
|
||||||
{cell, separated_cells} = List.pop_at(separated_cells, idx)
|
{cell, separated_cells} = List.pop_at(separated_cells, idx)
|
||||||
separated_cells = List.insert_at(separated_cells, new_idx, cell)
|
separated_cells = List.insert_at(separated_cells, new_idx, cell)
|
||||||
|
|
||||||
cell_groups =
|
cell_groups = group_cells(separated_cells)
|
||||||
separated_cells
|
|
||||||
|> Enum.chunk_by(&(&1 == :separator))
|
|
||||||
|> Enum.reject(&(&1 == [:separator]))
|
|
||||||
|
|
||||||
sections =
|
sections =
|
||||||
notebook.sections
|
notebook.sections
|
||||||
|
|
@ -204,6 +201,30 @@ defmodule Livebook.Notebook do
|
||||||
%{notebook | sections: sections}
|
%{notebook | sections: sections}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp group_cells(separated_cells) do
|
||||||
|
separated_cells
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> do_group_cells([])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_group_cells([], groups), do: groups
|
||||||
|
|
||||||
|
defp do_group_cells([:separator | separated_cells], []) do
|
||||||
|
do_group_cells(separated_cells, [[], []])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_group_cells([:separator | separated_cells], groups) do
|
||||||
|
do_group_cells(separated_cells, [[] | groups])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_group_cells([cell | separated_cells], []) do
|
||||||
|
do_group_cells(separated_cells, [[cell]])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_group_cells([cell | separated_cells], [group | groups]) do
|
||||||
|
do_group_cells(separated_cells, [[cell | group] | groups])
|
||||||
|
end
|
||||||
|
|
||||||
defp clamp_index(index, list) do
|
defp clamp_index(index, list) do
|
||||||
index |> max(0) |> min(length(list) - 1)
|
index |> max(0) |> min(length(list) - 1)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule LivebookWeb.SectionComponent do
|
||||||
<div class="flex space-x-4 items-center">
|
<div class="flex space-x-4 items-center">
|
||||||
<div class="flex flex-grow space-x-2 items-center text-gray-600">
|
<div class="flex flex-grow space-x-2 items-center text-gray-600">
|
||||||
<h2 class="flex-grow text-gray-900 font-semibold text-3xl py-2 border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
|
<h2 class="flex-grow text-gray-900 font-semibold text-3xl py-2 border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
|
||||||
data-section-name
|
data-element="section-name"
|
||||||
id="section-<%= @section.id %>-name"
|
id="section-<%= @section.id %>-name"
|
||||||
contenteditable
|
contenteditable
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
|
<div class="flex-grow px-6 py-8 flex overflow-y-auto" data-element="notebook">
|
||||||
<div class="max-w-screen-lg w-full mx-auto">
|
<div class="max-w-screen-lg w-full mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-gray-900 font-semibold text-4xl pb-2 border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
|
<h1 class="text-gray-900 font-semibold text-4xl pb-2 border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
|
||||||
|
|
@ -141,7 +141,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
phx-hook="ContentEditable"
|
phx-hook="ContentEditable"
|
||||||
data-update-attribute="phx-value-name"><%= @data.notebook.name %></h1>
|
data-update-attribute="phx-value-name"><%= @data.notebook.name %></h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-16 pb-80">
|
<div class="flex flex-col space-y-16">
|
||||||
<%= for section <- @data.notebook.sections do %>
|
<%= for section <- @data.notebook.sections do %>
|
||||||
<%= live_component @socket, LivebookWeb.SectionComponent,
|
<%= live_component @socket, LivebookWeb.SectionComponent,
|
||||||
id: section.id,
|
id: section.id,
|
||||||
|
|
@ -149,6 +149,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
section: section,
|
section: section,
|
||||||
cell_infos: @data.cell_infos %>
|
cell_infos: @data.cell_infos %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div style="height: 80vh"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,22 @@ defmodule Livebook.NotebookTest do
|
||||||
assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 2)
|
assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "move_cell/3" do
|
||||||
|
test "preserves empty sections" do
|
||||||
|
cell1 = %{Cell.new(:markdown) | id: "1"}
|
||||||
|
|
||||||
|
notebook = %{
|
||||||
|
Notebook.new()
|
||||||
|
| sections: [
|
||||||
|
%{Section.new() | cells: [cell1]},
|
||||||
|
%{Section.new() | cells: []}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
new_notebook = Notebook.move_cell(notebook, cell1.id, 1)
|
||||||
|
|
||||||
|
assert %{sections: [%{cells: []}, %{cells: [^cell1]}]} = new_notebook
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue