diff --git a/assets/css/global.css b/assets/css/global.css index d8dc3c4ef..b8d5a8aa5 100644 --- a/assets/css/global.css +++ b/assets/css/global.css @@ -5,5 +5,6 @@ button:focus { } body { - font-family: "Inter"; + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, + Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; } diff --git a/assets/css/session.css b/assets/css/session.css index 000cc6680..e9da3ea58 100644 --- a/assets/css/session.css +++ b/assets/css/session.css @@ -38,3 +38,7 @@ and entering/escaping insert mode. [data-element="cell"]:not([data-js-focused]) [data-element="actions"] { @apply hidden; } + +[data-element="section-list-item"][data-js-is-viewed] { + @apply text-current; +} diff --git a/assets/js/app.js b/assets/js/app.js index 72cc70e28..e7355b1b9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,5 +1,5 @@ import "../css/app.css"; -import 'remixicon/fonts/remixicon.css' +import "remixicon/fonts/remixicon.css"; import "@fontsource/inter"; import "@fontsource/inter/500.css"; diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js index 351250c1a..31801e98a 100644 --- a/assets/js/cell/index.js +++ b/assets/js/cell/index.js @@ -27,7 +27,9 @@ const Cell = { this.pushEvent("cell_init", { cell_id: this.props.cellId }, (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. editorContainer.firstElementChild.remove(); // Create an empty container for the editor to be mounted in. diff --git a/assets/js/cell/live_editor.js b/assets/js/cell/live_editor.js index 4618e3e3a..7e7156cf3 100644 --- a/assets/js/cell/live_editor.js +++ b/assets/js/cell/live_editor.js @@ -77,7 +77,7 @@ class LiveEditor { occurrencesHighlight: false, renderLineHighlight: "none", theme: "custom", - fontFamily: "JetBrains Mono" + fontFamily: "JetBrains Mono", }); this.editor.getModel().updateOptions({ diff --git a/assets/js/session/index.js b/assets/js/session/index.js index 411688eaa..09026ac2d 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -37,10 +37,16 @@ const Session = { document.addEventListener("mousedown", this.handleDocumentMouseDown); - this.el.querySelector(`[data-element="section-list"]`).addEventListener("click", event => { + getSectionList().addEventListener("click", (event) => { handleSectionListClick(this, event); }); + updateSectionListHighlight(); + + getNotebook().addEventListener("scroll", (event) => { + updateSectionListHighlight() + }); + // Server events 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 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 insertMode = editorClicked; if (hook.state.insertMode !== insertMode) { @@ -191,7 +199,9 @@ function handleDocumentMouseDown(hook, event) { * Handles section link clicks in the section list. */ 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) { const sectionId = sectionButton.getAttribute("data-section-id"); 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) function deleteFocusedCell(hook) { @@ -376,7 +416,7 @@ function handleCellMoved(hook, cellId) { function handleSectionInserted(hook, sectionId) { const section = getSectionById(sectionId); - const nameElement = section.querySelector("[data-section-name]"); + const nameElement = section.querySelector(`[data-element="section-name"]`); nameElement.focus({ preventScroll: true }); selectElementContent(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) { // Cancel any default browser behavior. event.preventDefault(); diff --git a/assets/test/lib/pub_sub.test.js b/assets/test/lib/pub_sub.test.js index 02575f0b2..b4e8cd30f 100644 --- a/assets/test/lib/pub_sub.test.js +++ b/assets/test/lib/pub_sub.test.js @@ -6,9 +6,9 @@ describe("PubSub", () => { const callback1 = jest.fn(); const callback2 = jest.fn(); - pubsub.subscribe('topic1', callback1); - pubsub.subscribe('topic2', callback2); - pubsub.broadcast('topic1', { data: 1 }); + pubsub.subscribe("topic1", callback1); + pubsub.subscribe("topic2", callback2); + pubsub.broadcast("topic1", { data: 1 }); expect(callback1).toHaveBeenCalledWith({ data: 1 }); expect(callback2).not.toHaveBeenCalled(); @@ -18,9 +18,9 @@ describe("PubSub", () => { const pubsub = new PubSub(); const callback1 = jest.fn(); - pubsub.subscribe('topic1', callback1); - pubsub.unsubscribe('topic1', callback1); - pubsub.broadcast('topic1', {}); + pubsub.subscribe("topic1", callback1); + pubsub.unsubscribe("topic1", callback1); + pubsub.broadcast("topic1", {}); expect(callback1).not.toHaveBeenCalled(); }); diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex index d549cf11a..792f19087 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -191,10 +191,7 @@ defmodule Livebook.Notebook do {cell, separated_cells} = List.pop_at(separated_cells, idx) separated_cells = List.insert_at(separated_cells, new_idx, cell) - cell_groups = - separated_cells - |> Enum.chunk_by(&(&1 == :separator)) - |> Enum.reject(&(&1 == [:separator])) + cell_groups = group_cells(separated_cells) sections = notebook.sections @@ -204,6 +201,30 @@ defmodule Livebook.Notebook do %{notebook | sections: sections} 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 index |> max(0) |> min(length(list) - 1) end diff --git a/lib/livebook_web/live/section_component.ex b/lib/livebook_web/live/section_component.ex index be245976f..60e1eceb6 100644 --- a/lib/livebook_web/live/section_component.ex +++ b/lib/livebook_web/live/section_component.ex @@ -7,7 +7,7 @@ defmodule LivebookWeb.SectionComponent do

-
+

<%= @data.notebook.name %>

-
+
<%= for section <- @data.notebook.sections do %> <%= live_component @socket, LivebookWeb.SectionComponent, id: section.id, @@ -149,6 +149,7 @@ defmodule LivebookWeb.SessionLive do section: section, cell_infos: @data.cell_infos %> <% end %> +
diff --git a/test/livebook/notebook_test.exs b/test/livebook/notebook_test.exs index d56abb55e..ef2e6c5f6 100644 --- a/test/livebook/notebook_test.exs +++ b/test/livebook/notebook_test.exs @@ -45,4 +45,22 @@ defmodule Livebook.NotebookTest do assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 2) 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