From aba0b732c1862096aac117736beea1e48668edf5 Mon Sep 17 00:00:00 2001 From: Jannik Becher Date: Tue, 25 Jul 2023 14:50:21 +0200 Subject: [PATCH 01/43] Add output panel --- assets/css/js_interop.css | 46 +++++- assets/js/hooks/external_window.js | 49 ++++++ assets/js/hooks/index.js | 2 + assets/js/hooks/session.js | 104 ++++++++++-- lib/livebook_web/helpers.ex | 62 +++++++ lib/livebook_web/live/app_session_live.ex | 58 ------- lib/livebook_web/live/session_live.ex | 38 +++++ .../live/session_live/external_window_live.ex | 152 ++++++++++++++++++ .../live/session_live/indicators_component.ex | 28 +++- lib/livebook_web/router.ex | 1 + 10 files changed, 463 insertions(+), 77 deletions(-) create mode 100644 assets/js/hooks/external_window.js create mode 100644 lib/livebook_web/live/session_live/external_window_live.ex diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index 891896175d1..1edf48a5a7b 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -296,20 +296,58 @@ solely client-side operations. /* === Session views === */ -[data-el-session][data-js-view="code-zen"] [data-el-view-toggle="code-zen"] { +[data-el-session][data-js-view] [data-el-view-activate] { + @apply pointer-events-none; +} + +[data-el-session]:not([data-js-view]) [data-el-view-deactivate-button] { + @apply hidden; +} + +[data-el-session][data-js-view="output-panel-popped-out"] + [data-el-view-activate="output-panel"], +[data-el-session]:not([data-js-view="output-panel-popped-out"]) + [data-el-view-output-panel-popin-button] { + @apply hidden; +} + +[data-el-session][data-js-view="output-panel"] + [data-el-view-activate="output-panel"], +[data-el-session][data-js-view="output-panel-popped-out"] + [data-el-view-output-panel-popin-button] { + @apply text-green-bright-400; +} + +[data-el-session][data-js-view="code-zen"] [data-el-view-activate="code-zen"] { @apply text-green-bright-400; } [data-el-session][data-js-view="presentation"] - [data-el-view-toggle="presentation"] { + [data-el-view-activate="presentation"] { @apply text-green-bright-400; } -[data-el-session][data-js-view="custom"] [data-el-view-toggle="custom"] { +[data-el-session][data-js-view="custom"] [data-el-view-activate="custom"] { @apply text-green-bright-400; } -[data-el-session][data-js-view] +[data-el-session]:not([data-js-view="output-panel"]) [data-el-output-panel] { + @apply hidden; +} + +[data-el-session][data-js-view="output-panel"] [data-el-notebook-content] { + @apply absolute w-1/2 left-0 px-16 py-4; +} + +[data-el-session][data-js-view="output-panel"] [data-el-notebook-indicators] { + @apply sm:fixed bottom-[0.4rem] right-1/2; +} + +[data-el-session]:is( + [data-js-view="code-zen"], + [data-js-view="presentation"], + [data-js-view="custom"] + ) :is([data-el-actions], [data-el-insert-buttons]) { @apply hidden; } diff --git a/assets/js/hooks/external_window.js b/assets/js/hooks/external_window.js new file mode 100644 index 00000000000..745ecdc32be --- /dev/null +++ b/assets/js/hooks/external_window.js @@ -0,0 +1,49 @@ +import { getAttributeOrDefault, parseBoolean } from "../lib/attribute"; +/** + * A hook for external windows. + */ +const ExternalWindow = { + mounted() { + this.props = this.getProps(); + + if (!this.props.isWindowEmbedded) { + this.handleBeforeUnloadEvent = this.handleBeforeUnloadEvent.bind(this); + window.addEventListener("beforeunload", this.handleBeforeUnloadEvent); + this.getElement("external-window-close-button").addEventListener( + "click", + (event) => this.handleExternalWindowCloseClick() + ); + this.getElement("external-window-popin-button").addEventListener( + "click", + (event) => this.handleExternalWindowPopinClick() + ); + } + }, + updated() { + this.props = this.getProps(); + }, + getProps() { + return { + isWindowEmbedded: this.el.hasAttribute("data-window-embedded"), + }; + }, + handleBeforeUnloadEvent(event) { + this.sendToParent("external_window_popin_clicked"); + }, + handleExternalWindowCloseClick() { + window.removeEventListener("beforeunload", this.handleBeforeUnloadEvent); + this.sendToParent("external_window_close_clicked"); + }, + handleExternalWindowPopinClick() { + window.removeEventListener("beforeunload", this.handleBeforeUnloadEvent); + this.sendToParent("external_window_popin_clicked"); + }, + getElement(name) { + return document.querySelector(`[data-el-${name}]`); + }, + sendToParent(message) { + window.opener.postMessage(message, window.location.origin); + }, +}; + +export default ExternalWindow; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index c33c410dc35..cf8b4b0ac8c 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -5,6 +5,7 @@ import CellEditor from "./cell_editor"; import Dropzone from "./dropzone"; import EditorSettings from "./editor_settings"; import EmojiPicker from "./emoji_picker"; +import ExternalWindow from "./external_window"; import FocusOnUpdate from "./focus_on_update"; import Headline from "./headline"; import Highlight from "./highlight"; @@ -31,6 +32,7 @@ export default { Dropzone, EditorSettings, EmojiPicker, + ExternalWindow, FocusOnUpdate, Headline, Highlight, diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 5d787aafafc..f400b9fbc39 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -76,6 +76,10 @@ const Session = { this.clientsMap = {}; this.lastLocationReportByClientId = {}; this.followedClientId = null; + this.outputPanelWindow = null; + + this.handleExternalWindowMessage = + this.handleExternalWindowMessage.bind(this); setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus)); @@ -135,9 +139,29 @@ const Session = { this.handleCellIndicatorsClick(event) ); - this.getElement("views").addEventListener("click", (event) => { - this.handleViewsClick(event); - }); + this.getElement("views").addEventListener("click", (event) => + this.handleActivateViewClick(event) + ); + + this.getElement("view-deactivate-button").addEventListener( + "click", + (event) => this.handleDeactivateViewClick() + ); + + this.getElement("output-panel-close-button").addEventListener( + "click", + (event) => this.handleOutputPanelCloseClick() + ); + + this.getElement("output-panel-popout-button").addEventListener( + "click", + (event) => this.handleOutputPanelPopoutClick() + ); + + this.getElement("view-output-panel-popin-button").addEventListener( + "click", + (event) => this.handleOutputPanelPopinClick() + ); this.getElement("section-toggle-collapse-all-button").addEventListener( "click", @@ -663,6 +687,26 @@ const Session = { } }, + handleOutputPanelCloseClick() { + this.closeOutputPanelWindow(); + this.el.removeAttribute("data-js-view"); + }, + + handleOutputPanelPopoutClick() { + this.outputPanelWindow = window.open( + window.location.pathname + `/external-window?type=output-panel`, + "_blank", + "toolbar=no, location=no, directories=no, titlebar=no, toolbar=0, status=no, menubar=no, scrollbars=yes, resizable=yes, copyhistory=yes, width=600, height=600" + ); + window.addEventListener("message", this.handleExternalWindowMessage); + this.el.setAttribute("data-js-view", "output-panel-popped-out"); + }, + + handleOutputPanelPopinClick() { + this.closeOutputPanelWindow(); + this.el.setAttribute("data-js-view", "output-panel"); + }, + /** * Focuses cell or any other element based on the current * URL and hook attributes. @@ -1085,22 +1129,35 @@ const Session = { }); }, - handleViewsClick(event) { - const button = event.target.closest(`[data-el-view-toggle]`); + handleActivateViewClick(event) { + const button = event.target.closest(`[data-el-view-activate]`); if (button) { - const view = button.getAttribute("data-el-view-toggle"); - this.toggleView(view); + const view = button.getAttribute("data-el-view-activate"); + this.activateView(view); } }, + handleDeactivateViewClick() { + this.deactivateView(); + }, + toggleView(view) { - if (view === this.view) { - this.unsetView(); + if (this.view) { + this.deactivateView(); + } else { + this.activateView(view); + } + }, - if (view === "custom") { - this.unsubscribeCustomViewFromSettings(); - } + activateView(view) { + if (view === "output-panel") { + this.setView(view, { + showSection: true, + showMarkdown: false, + showOutput: true, + spotlight: false, + }); } else if (view === "code-zen") { this.setView(view, { showSection: false, @@ -1146,6 +1203,14 @@ const Session = { } }, + deactivateView() { + this.unsetView(); + + if (this.view === "custom") { + this.unsubscribeCustomViewFromSettings(); + } + }, + setView(view, options) { this.view = view; this.viewOptions = options; @@ -1502,6 +1567,21 @@ const Session = { getElement(name) { return this.el.querySelector(`[data-el-${name}]`); }, + + closeOutputPanelWindow() { + window.removeEventListener("message", this.handleExternalWindowMessage); + this.outputPanelWindow && this.outputPanelWindow.close(); + this.outputPanelWindow = null; + }, + + handleExternalWindowMessage(event) { + if (event.origin != window.location.origin) return; + if (event.data === "external_window_popin_clicked") { + this.handleOutputPanelPopinClick(); + } else if (event.data === "external_window_close_clicked") { + this.handleOutputPanelCloseClick(); + } + }, }; /** diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index 80738b73263..9d87b7076d2 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -3,6 +3,8 @@ defmodule LivebookWeb.Helpers do use LivebookWeb, :verified_routes + alias Livebook.Notebook.Cell + @doc """ Determines user platform based on the given *User-Agent* header. """ @@ -82,4 +84,64 @@ defmodule LivebookWeb.Helpers do def format_datetime_relatively(date) do date |> DateTime.to_naive() |> Livebook.Utils.Time.time_ago_in_words() end + + # TODO + def input_views_for_output(output, data, changed_input_ids) do + input_ids = for attrs <- Cell.find_inputs_in_output(output), do: attrs.id + + data.input_infos + |> Map.take(input_ids) + |> Map.new(fn {input_id, %{value: value}} -> + {input_id, %{value: value, changed: MapSet.member?(changed_input_ids, input_id)}} + end) + end + + # TODO + def visible_outputs(notebook) do + for section <- Enum.reverse(notebook.sections), + cell <- Enum.reverse(section.cells), + Cell.evaluable?(cell), + output <- filter_outputs(cell.outputs, notebook.app_settings.output_type), + do: {cell.id, output} + end + + defp filter_outputs(outputs, :all), do: outputs + defp filter_outputs(outputs, :rich), do: rich_outputs(outputs) + + defp rich_outputs(outputs) do + for output <- outputs, output = filter_output(output), do: output + end + + defp filter_output({idx, output}) + when elem(output, 0) in [:plain_text, :markdown, :image, :js, :control, :input], + do: {idx, output} + + defp filter_output({idx, {:tabs, outputs, metadata}}) do + outputs_with_labels = + for {output, label} <- Enum.zip(outputs, metadata.labels), + output = filter_output(output), + do: {output, label} + + {outputs, labels} = Enum.unzip(outputs_with_labels) + + {idx, {:tabs, outputs, %{metadata | labels: labels}}} + end + + defp filter_output({idx, {:grid, outputs, metadata}}) do + outputs = rich_outputs(outputs) + + if outputs != [] do + {idx, {:grid, outputs, metadata}} + end + end + + defp filter_output({idx, {:frame, outputs, metadata}}) do + outputs = rich_outputs(outputs) + {idx, {:frame, outputs, metadata}} + end + + defp filter_output({idx, {:error, _message, {:interrupt, _, _}} = output}), + do: {idx, output} + + defp filter_output(_output), do: nil end diff --git a/lib/livebook_web/live/app_session_live.ex b/lib/livebook_web/live/app_session_live.ex index 038a98c3db7..65ccde2f09b 100644 --- a/lib/livebook_web/live/app_session_live.ex +++ b/lib/livebook_web/live/app_session_live.ex @@ -433,62 +433,4 @@ defmodule LivebookWeb.AppSessionLive do defp any_stale?(data) do Enum.any?(data.cell_infos, &match?({_, %{eval: %{validity: :stale}}}, &1)) end - - defp input_views_for_output(output, data, changed_input_ids) do - input_ids = for attrs <- Cell.find_inputs_in_output(output), do: attrs.id - - data.input_infos - |> Map.take(input_ids) - |> Map.new(fn {input_id, %{value: value}} -> - {input_id, %{value: value, changed: MapSet.member?(changed_input_ids, input_id)}} - end) - end - - defp visible_outputs(notebook) do - for section <- Enum.reverse(notebook.sections), - cell <- Enum.reverse(section.cells), - Cell.evaluable?(cell), - output <- filter_outputs(cell.outputs, notebook.app_settings.output_type), - do: {cell.id, output} - end - - defp filter_outputs(outputs, :all), do: outputs - defp filter_outputs(outputs, :rich), do: rich_outputs(outputs) - - defp rich_outputs(outputs) do - for output <- outputs, output = filter_output(output), do: output - end - - defp filter_output({idx, output}) - when elem(output, 0) in [:plain_text, :markdown, :image, :js, :control, :input], - do: {idx, output} - - defp filter_output({idx, {:tabs, outputs, metadata}}) do - outputs_with_labels = - for {output, label} <- Enum.zip(outputs, metadata.labels), - output = filter_output(output), - do: {output, label} - - {outputs, labels} = Enum.unzip(outputs_with_labels) - - {idx, {:tabs, outputs, %{metadata | labels: labels}}} - end - - defp filter_output({idx, {:grid, outputs, metadata}}) do - outputs = rich_outputs(outputs) - - if outputs != [] do - {idx, {:grid, outputs, metadata}} - end - end - - defp filter_output({idx, {:frame, outputs, metadata}}) do - outputs = rich_outputs(outputs) - {idx, {:frame, outputs, metadata}} - end - - defp filter_output({idx, {:error, _message, {:interrupt, _, _}} = output}), - do: {idx, output} - - defp filter_output(_output), do: nil end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index ef5d60aef21..8632e8230e7 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -419,6 +419,13 @@ defmodule LivebookWeb.SessionLive do
+
+