From 46a94522989fd0758cff884d6133bd723d1e7a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Mon, 24 Jul 2023 14:20:42 +0200 Subject: [PATCH] Add notebook custom view (#2101) Co-authored-by: gitstart Co-authored-by: Hameed Abdulrahaman Co-authored-by: gitstart_bot --- assets/css/js_interop.css | 72 +++++++------- assets/js/hooks/custom_view_settings.js | 45 +++++++++ assets/js/hooks/index.js | 2 + assets/js/hooks/session.js | 93 +++++++++++++++---- assets/js/lib/settings.js | 25 +++++ lib/livebook_web/live/session_live.ex | 20 ++++ .../session_live/custom_view_component.ex | 37 ++++++++ .../live/session_live/indicators_component.ex | 6 ++ .../live/session_live/shortcuts_component.ex | 1 + lib/livebook_web/router.ex | 1 + 10 files changed, 250 insertions(+), 52 deletions(-) create mode 100644 assets/js/hooks/custom_view_settings.js create mode 100644 lib/livebook_web/live/session_live/custom_view_component.ex diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index e86daf269e4..864c3d9f0e6 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -294,55 +294,61 @@ solely client-side operations. @apply hidden; } -/* === Views === */ - -[data-el-session][data-js-view="code-zen"] [data-el-section-headline], -[data-el-session][data-js-view="code-zen"] [data-el-section-subheadline], -[data-el-session][data-js-view="code-zen"] - [data-el-section-subheadline-collapsed], -[data-el-session][data-js-view="code-zen"] [data-el-cell][data-type="markdown"], -[data-el-session][data-js-view="code-zen"] [data-el-actions], -[data-el-session][data-js-view="code-zen"] [data-el-insert-buttons] { - @apply hidden; +/* === Session views === */ + +[data-el-session][data-js-view="code-zen"] [data-el-view-toggle="code-zen"] { + @apply text-green-bright-400; } -[data-el-session][data-js-view="code-zen"] [data-el-sections-container] { - @apply space-y-0 mt-0; +[data-el-session][data-js-view="presentation"] + [data-el-view-toggle="presentation"] { + @apply text-green-bright-400; } -[data-el-session][data-js-view="code-zen"] [data-el-view-toggle="code-zen"] { +[data-el-session][data-js-view="custom"] [data-el-view-toggle="custom"] { @apply text-green-bright-400; } -[data-el-session][data-js-view="presentation"] - [data-el-section-headline]:not([data-js-focused]), -[data-el-session][data-js-view="presentation"] - [data-el-section-subheadline]:not([data-js-focused]), -[data-el-session][data-js-view="presentation"] - [data-el-cell]:not([data-js-focused]), -[data-el-session][data-js-view="presentation"] - [data-el-js-view-iframes] - iframe:not([data-js-focused]) { - @apply opacity-10; +[data-el-session][data-js-view] [data-el-actions], +[data-el-session][data-js-view] [data-el-insert-buttons] { + @apply hidden; } -[data-el-session][data-js-view="presentation"] [data-el-sidebar], -[data-el-session][data-js-view="presentation"] [data-el-side-panel], -[data-el-session][data-js-view="presentation"] [data-el-toggle-sidebar] { +[data-el-session]:is([data-js-view]) [data-el-views-disabled] { @apply hidden; } -[data-el-session][data-js-view="presentation"] - [data-el-view-toggle="presentation"] { - @apply text-green-bright-400; +[data-el-session]:not([data-js-view]) [data-el-views-enabled] { + @apply hidden; +} + +[data-js-hide-output] [data-el-output] { + @apply hidden; } -[data-el-session]:is([data-js-view="code-zen"], [data-js-view="presentation"]) - [data-el-views-disabled] { +[data-js-hide-section] [data-el-section-headline], +[data-js-hide-section] [data-el-section-subheadline], +[data-js-hide-section] [data-el-section-subheadline-collapsed] { @apply hidden; } -[data-el-session]:not([data-js-view="code-zen"], [data-js-view="presentation"]) - [data-el-views-enabled] { +[data-js-hide-section] [data-el-sections-container] { + @apply space-y-0 mt-0; +} + +[data-js-hide-markdown] [data-el-cell][data-type="markdown"] { + @apply hidden; +} + +[data-js-spotlight] [data-el-section-headline]:not([data-js-focused]), +[data-js-spotlight] [data-el-section-subheadline]:not([data-js-focused]), +[data-js-spotlight] [data-el-cell]:not([data-js-focused]), +[data-js-spotlight] [data-el-js-view-iframes] iframe:not([data-js-focused]) { + @apply opacity-10; +} + +[data-js-spotlight] [data-el-sidebar], +[data-js-spotlight] [data-el-side-panel], +[data-js-spotlight] [data-el-toggle-sidebar] { @apply hidden; } diff --git a/assets/js/hooks/custom_view_settings.js b/assets/js/hooks/custom_view_settings.js new file mode 100644 index 00000000000..b6574e83663 --- /dev/null +++ b/assets/js/hooks/custom_view_settings.js @@ -0,0 +1,45 @@ +import { settingsStore } from "../lib/settings"; + +/** + * A hook for the custom view settings. + */ +const CustomViewSettings = { + mounted() { + const settings = settingsStore.get(); + + const customSectionCheckbox = this.el.querySelector( + `[name="show_section"][value="true"]` + ); + const customMarkdownCheckbox = this.el.querySelector( + `[name="show_markdown"][value="true"]` + ); + const customOutputCheckbox = this.el.querySelector( + `[name="show_output"][value="true"]` + ); + const customSpotlightCheckbox = this.el.querySelector( + `[name="spotlight"][value="true"]` + ); + + customSectionCheckbox.checked = settings.custom_view_show_section; + customMarkdownCheckbox.checked = settings.custom_view_show_markdown; + customOutputCheckbox.checked = settings.custom_view_show_output; + customSpotlightCheckbox.checked = settings.custom_view_spotlight; + + customSectionCheckbox.addEventListener("change", (event) => { + settingsStore.update({ custom_view_show_section: event.target.checked }); + }); + customMarkdownCheckbox.addEventListener("change", (event) => { + settingsStore.update({ custom_view_show_markdown: event.target.checked }); + }); + customOutputCheckbox.addEventListener("change", (event) => { + settingsStore.update({ custom_view_show_output: event.target.checked }); + }); + customSpotlightCheckbox.addEventListener("change", (event) => { + settingsStore.update({ + custom_view_spotlight: event.target.checked, + }); + }); + }, +}; + +export default CustomViewSettings; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index f960ce98e75..c33c410dc35 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -21,6 +21,7 @@ import UserForm from "./user_form"; import UtcDateTimeInput from "./utc_datetime_input"; import UtcTimeInput from "./utc_time_input"; import VirtualizedLines from "./virtualized_lines"; +import CustomViewSettings from "./custom_view_settings"; export default { AppAuth, @@ -46,4 +47,5 @@ export default { UtcDateTimeInput, UtcTimeInput, VirtualizedLines, + CustomViewSettings, }; diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index bc7fa655729..5d787aafafc 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -15,6 +15,7 @@ import { globalPubSub } from "../lib/pub_sub"; import monaco from "./cell_editor/live_editor/monaco"; import { leaveChannel } from "./js_view/channel"; import { isDirectlyEditable, isEvaluable } from "../lib/notebook"; +import { settingsStore } from "../lib/settings"; /** * A hook managing the whole session. @@ -70,6 +71,7 @@ const Session = { this.focusedId = null; this.insertMode = false; this.view = null; + this.viewOptions = null; this.keyBuffer = new KeyBuffer(); this.clientsMap = {}; this.lastLocationReportByClientId = {}; @@ -415,17 +417,27 @@ const Session = { } else if (keyBuffer.tryMatch(["N"])) { this.insertCellAboveFocused("code"); } else if (keyBuffer.tryMatch(["m"])) { - !this.isViewCodeZen() && this.insertCellBelowFocused("markdown"); + if (!this.view || this.viewOptions.showMarkdown) { + this.insertCellBelowFocused("markdown"); + } } else if (keyBuffer.tryMatch(["M"])) { - !this.isViewCodeZen() && this.insertCellAboveFocused("markdown"); + if (!this.view || this.viewOptions.showMarkdown) { + this.insertCellAboveFocused("markdown"); + } } else if (keyBuffer.tryMatch(["v", "z"])) { this.toggleView("code-zen"); } else if (keyBuffer.tryMatch(["v", "p"])) { this.toggleView("presentation"); + } else if (keyBuffer.tryMatch(["v", "c"])) { + this.toggleView("custom"); } else if (keyBuffer.tryMatch(["c"])) { - !this.isViewCodeZen() && this.toggleCollapseSection(); + if (!this.view || this.viewOptions.showSection) { + this.toggleCollapseSection(); + } } else if (keyBuffer.tryMatch(["C"])) { - !this.isViewCodeZen() && this.toggleCollapseAllSections(); + if (!this.view || this.viewOptions.showSection) { + this.toggleCollapseAllSections(); + } } } }, @@ -1083,12 +1095,39 @@ const Session = { }, toggleView(view) { - if (this.view === view) { - this.view = null; - this.el.removeAttribute("data-js-view"); - } else { - this.view = view; - this.el.setAttribute("data-js-view", view); + if (view === this.view) { + this.unsetView(); + + if (view === "custom") { + this.unsubscribeCustomViewFromSettings(); + } + } else if (view === "code-zen") { + this.setView(view, { + showSection: false, + showMarkdown: false, + showOutput: true, + spotlight: false, + }); + } else if (view === "presentation") { + this.setView(view, { + showSection: true, + showMarkdown: true, + showOutput: true, + spotlight: true, + }); + } else if (view === "custom") { + this.unsubscribeCustomViewFromSettings = settingsStore.getAndSubscribe( + (settings) => { + this.setView(view, { + showSection: settings.custom_view_show_section, + showMarkdown: settings.custom_view_show_markdown, + showOutput: settings.custom_view_show_output, + spotlight: settings.custom_view_spotlight, + }); + } + ); + + this.pushEvent("open_custom_view_settings"); } // If nothing is focused, use the first cell in the viewport @@ -1107,6 +1146,30 @@ const Session = { } }, + setView(view, options) { + this.view = view; + this.viewOptions = options; + + this.el.setAttribute("data-js-view", view); + + this.el.toggleAttribute("data-js-hide-section", !options.showSection); + this.el.toggleAttribute("data-js-hide-markdown", !options.showMarkdown); + this.el.toggleAttribute("data-js-hide-output", !options.showOutput); + this.el.toggleAttribute("data-js-spotlight", options.spotlight); + }, + + unsetView() { + this.view = null; + this.viewOptions = null; + + this.el.removeAttribute("data-js-view"); + + this.el.removeAttribute("data-js-hide-section"); + this.el.removeAttribute("data-js-hide-markdown"); + this.el.removeAttribute("data-js-hide-output"); + this.el.removeAttribute("data-js-spotlight"); + }, + toggleCollapseSection() { if (this.focusedId) { const sectionId = this.getSectionIdByFocusableId(this.focusedId); @@ -1152,7 +1215,7 @@ const Session = { handleCellDeleted(cellId, siblingCellId) { if (this.focusedId === cellId) { - if (this.isViewCodeZen()) { + if (this.view) { const visibleSiblingId = this.ensureVisibleFocusableEl(siblingCellId); this.setFocusedEl(visibleSiblingId); } else { @@ -1439,14 +1502,6 @@ const Session = { getElement(name) { return this.el.querySelector(`[data-el-${name}]`); }, - - isViewCodeZen() { - return this.view === "code-zen"; - }, - - isViewPresentation() { - return this.view === "presentation"; - }, }; /** diff --git a/assets/js/lib/settings.js b/assets/js/lib/settings.js index f7a672b690a..308529cbe95 100644 --- a/assets/js/lib/settings.js +++ b/assets/js/lib/settings.js @@ -18,6 +18,10 @@ const DEFAULT_SETTINGS = { editor_font_size: EDITOR_FONT_SIZE.normal, editor_theme: EDITOR_THEME.default, editor_markdown_word_wrap: true, + custom_view_show_section: true, + custom_view_show_markdown: true, + custom_view_show_output: true, + custom_view_spotlight: false, }; /** @@ -57,10 +61,31 @@ class SettingsStore { * * The given function is called immediately with the current * settings and then on every change. + * + * Returns a function that unsubscribes as a shorthand for + * `unsubscribe`. */ getAndSubscribe(callback) { this._subscribers.push(callback); callback(this._settings); + + return () => { + this.unsubscribe(callback); + }; + } + + /** + * Unsubscribes the given function from updates. + * + * Note that you must pass the same function reference as you + * passed to `subscribe`. + */ + unsubscribe(callback) { + const index = this._subscribers.indexOf(callback); + + if (index !== -1) { + this._subscribers.splice(index, 1); + } } _loadSettings() { diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 5cd6ef254aa..ef5d60aef21 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -593,6 +593,21 @@ defmodule LivebookWeb.SessionLive do return_to={@self_path} /> + + <.modal + :if={@live_action == :custom_view_settings} + id="custom-view-modal" + show + width={:medium} + patch={@self_path} + > + <.live_component + module={LivebookWeb.SessionLive.CustomViewComponent} + id="custom" + return_to={@self_path} + session={@session} + /> + """ end @@ -1613,6 +1628,11 @@ defmodule LivebookWeb.SessionLive do |> push_event("finish_file_drop", %{})} end + def handle_event("open_custom_view_settings", %{}, socket) do + {:noreply, + push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}/settings/custom-view")} + end + @impl true def handle_info({:operation, operation}, socket) do {:noreply, handle_operation(socket, operation)} diff --git a/lib/livebook_web/live/session_live/custom_view_component.ex b/lib/livebook_web/live/session_live/custom_view_component.ex new file mode 100644 index 00000000000..e8aa9598e9b --- /dev/null +++ b/lib/livebook_web/live/session_live/custom_view_component.ex @@ -0,0 +1,37 @@ +defmodule LivebookWeb.SessionLive.CustomViewComponent do + use LivebookWeb, :live_component + + @impl true + def render(assigns) do + ~H""" +
+

+ Custom view +

+

+ Configure notebook display options. +

+

+ Options +

+
+ <.switch_field name="show_section" label="Show sections" value={false} /> + <.switch_field name="show_markdown" label="Show markdown" value={false} /> + <.switch_field name="show_output" label="Show outputs" value={false} /> + <.switch_field name="spotlight" label="Spotlight focused" value={false} /> +
+
+ <.link patch={@return_to} class="button-base button-outlined-gray"> + Close + +
+
+ """ + end +end diff --git a/lib/livebook_web/live/session_live/indicators_component.ex b/lib/livebook_web/live/session_live/indicators_component.ex index efd471e77d2..42f1b2d6c28 100644 --- a/lib/livebook_web/live/session_live/indicators_component.ex +++ b/lib/livebook_web/live/session_live/indicators_component.ex @@ -85,6 +85,12 @@ defmodule LivebookWeb.SessionLive.IndicatorsComponent do Presentation + <.menu_item> + + """ diff --git a/lib/livebook_web/live/session_live/shortcuts_component.ex b/lib/livebook_web/live/session_live/shortcuts_component.ex index a54b361efba..e39133f8764 100644 --- a/lib/livebook_web/live/session_live/shortcuts_component.ex +++ b/lib/livebook_web/live/session_live/shortcuts_component.ex @@ -95,6 +95,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do %{seq: ["C"], desc: "Expand/collapse all sections"}, %{seq: ["v", "z"], desc: "Toggle code zen view"}, %{seq: ["v", "p"], desc: "Toggle presentation view"}, + %{seq: ["v", "c"], desc: "Toggle custom view"}, %{seq: ["d", "d"], desc: "Delete cell", basic: true}, %{seq: ["e", "e"], desc: "Evaluate cell"}, %{seq: ["e", "s"], desc: "Evaluate section"}, diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index d24d1193dae..5e664741d8d 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -96,6 +96,7 @@ defmodule LivebookWeb.Router do live "/sessions/:id/package-search", SessionLive, :package_search get "/sessions/:id/files/:name", SessionController, :show_file get "/sessions/:id/images/:name", SessionController, :show_image + live "/sessions/:id/settings/custom-view", SessionLive, :custom_view_settings live "/sessions/:id/*path_parts", SessionLive, :catch_all end