diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index f0494ef339d..8e8ecfe6c5a 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -13,7 +13,7 @@ solely client-side operations. @apply hidden; } -[phx-hook="Dropzone"][data-js-dragging] { +[phx-hook="FileDropzone"][data-js-dragging] { @apply bg-blue-100 border-blue-300; } @@ -300,20 +300,55 @@ 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-embedded] { + @apply hidden; +} + +[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; } @@ -361,3 +396,46 @@ solely client-side operations. :is([data-el-sidebar], [data-el-side-panel], [data-el-toggle-sidebar]) { @apply hidden; } + +[data-el-session][data-js-view^="output-panel"] + ~ #app-settings-modal + [data-el-app-settings-enable-output-panel-button] { + @apply hidden; +} + +[data-el-session]:not([data-js-view="output-panel-popped-out"]) + ~ #app-settings-modal + [data-el-app-settings-popin-output-panel-button] { + @apply hidden; +} + +/* === Output Panel === */ + +[data-el-output-panel-content]:not([data-js-dragging]) + [data-el-output-panel-col-drop-area], +[data-el-output-panel-content]:not([data-js-dragging]) + [data-el-output-panel-row-drop-area] { + @apply opacity-0; +} + +[data-el-output-panel-content][data-js-dragging] + [data-el-output-panel-col-drop-area]:not([data-js-dragging]), +[data-el-output-panel-content][data-js-dragging] + [data-el-output-panel-row-drop-area]:not([data-js-dragging]) { + @apply z-20 opacity-0; +} + +[data-el-output-panel-col-drop-area][data-js-dragging] { + @apply z-20 opacity-100; +} + +[data-el-output-panel-row-drop-area][data-js-dragging] { + @apply z-20 h-16 opacity-100; +} + +[data-el-output-panel-col-drop-area][data-js-dragging-source], +[data-el-output-panel-row][data-js-dragging-source] + [data-el-output-panel-col-drop-area] + div { + @apply hidden; +} diff --git a/assets/js/hooks/external_window.js b/assets/js/hooks/external_window.js new file mode 100644 index 00000000000..187e1ab75e3 --- /dev/null +++ b/assets/js/hooks/external_window.js @@ -0,0 +1,59 @@ +import { globalPubSub } from "../lib/pub_sub"; +/** + * 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() + ); + } + + this.handleEvent("output_panel_updated", ({ cell_id }) => { + this.handleOutputPanelItemMoved(cell_id); + }); + }, + 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"); + }, + handleOutputPanelItemMoved(cell_id) { + this.repositionJSViews(); + }, + getElement(name) { + return document.querySelector(`[data-el-${name}]`); + }, + sendToParent(message) { + window.opener.postMessage(message, window.location.origin); + }, + repositionJSViews() { + globalPubSub.broadcast("js_views", { type: "reposition" }); + }, +}; + +export default ExternalWindow; diff --git a/assets/js/hooks/dropzone.js b/assets/js/hooks/file_dropzone.js similarity index 90% rename from assets/js/hooks/dropzone.js rename to assets/js/hooks/file_dropzone.js index 899da9851c0..dc0677810c0 100644 --- a/assets/js/hooks/dropzone.js +++ b/assets/js/hooks/file_dropzone.js @@ -3,7 +3,7 @@ const DRAGGING_ATTR = "data-js-dragging"; /** * A hook used to highlight drop zone when dragging a file. */ -const Dropzone = { +const FileDropzone = { mounted() { this.el.addEventListener("dragenter", (event) => { this.el.setAttribute(DRAGGING_ATTR, ""); @@ -21,4 +21,4 @@ const Dropzone = { }, }; -export default Dropzone; +export default FileDropzone; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index c33c410dc35..d0c288d0d2b 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -2,9 +2,10 @@ import AppAuth from "./app_auth"; import AudioInput from "./audio_input"; import Cell from "./cell"; 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 FileDropzone from "./file_dropzone"; import FocusOnUpdate from "./focus_on_update"; import Headline from "./headline"; import Highlight from "./highlight"; @@ -13,6 +14,9 @@ import ImageOutput from "./image_output"; import JSView from "./js_view"; import KeyboardControl from "./keyboard_control"; import MarkdownRenderer from "./markdown_renderer"; +import OutputPanel from "./output_panel"; +import OutputPanelItem from "./output_panel_item"; +import OutputPanelDropzone from "./output_panel_dropzone"; import ScrollOnUpdate from "./scroll_on_update"; import Session from "./session"; import TextareaAutosize from "./textarea_autosize"; @@ -28,9 +32,10 @@ export default { AudioInput, Cell, CellEditor, - Dropzone, EditorSettings, EmojiPicker, + ExternalWindow, + FileDropzone, FocusOnUpdate, Headline, Highlight, @@ -39,6 +44,9 @@ export default { JSView, KeyboardControl, MarkdownRenderer, + OutputPanel, + OutputPanelItem, + OutputPanelDropzone, ScrollOnUpdate, Session, TextareaAutosize, diff --git a/assets/js/hooks/js_view.js b/assets/js/hooks/js_view.js index bd4f47cae3f..1d53662ed04 100644 --- a/assets/js/hooks/js_view.js +++ b/assets/js/hooks/js_view.js @@ -221,9 +221,9 @@ const JSView = { this.iframe.className = "w-full h-0 absolute z-[1]"; const notebookEl = document.querySelector(`[data-el-notebook]`); - const notebookContentEl = notebookEl.querySelector( - `[data-el-notebook-content]` - ); + const notebookContentEl = + notebookEl && notebookEl.querySelector(`[data-el-notebook-content]`); + const outputPanelEl = document.querySelector(`[data-el-output-panel]`); // Most placeholder position changes are accompanied by changes to the // notebook content element height (adding cells, inserting newlines @@ -234,8 +234,9 @@ const JSView = { const resizeObserver = new ResizeObserver((entries) => { this.repositionIframe(); }); - resizeObserver.observe(notebookContentEl); - resizeObserver.observe(notebookEl); + notebookContentEl && resizeObserver.observe(notebookContentEl); + notebookEl && resizeObserver.observe(notebookEl); + outputPanelEl && resizeObserver.observe(outputPanelEl); // On certain events, like section/cell moved, a global event is // dispatched to trigger reposition. This way we don't need to @@ -307,19 +308,21 @@ const JSView = { repositionIframe() { const { iframe, iframePlaceholder } = this; - const notebookEl = document.querySelector(`[data-el-notebook]`); + const containerEl = + document.querySelector(`[data-el-notebook]`) || + document.querySelector(`[data-el-output-panel]`); if (isElementHidden(iframePlaceholder)) { // When the placeholder is hidden, we hide the iframe as well iframe.classList.add("hidden"); } else { iframe.classList.remove("hidden"); - const notebookBox = notebookEl.getBoundingClientRect(); + const notebookBox = containerEl.getBoundingClientRect(); const placeholderBox = iframePlaceholder.getBoundingClientRect(); - const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop; + const top = placeholderBox.top - notebookBox.top + containerEl.scrollTop; iframe.style.top = `${top}px`; const left = - placeholderBox.left - notebookBox.left + notebookEl.scrollLeft; + placeholderBox.left - notebookBox.left + containerEl.scrollLeft; iframe.style.left = `${left}px`; iframe.style.height = `${placeholderBox.height}px`; iframe.style.width = `${placeholderBox.width}px`; diff --git a/assets/js/hooks/output_panel.js b/assets/js/hooks/output_panel.js new file mode 100644 index 00000000000..7a2a8a6c374 --- /dev/null +++ b/assets/js/hooks/output_panel.js @@ -0,0 +1,137 @@ +import { + getAttributeOrThrow, + getAttributeOrDefault, + parseInteger, +} from "../lib/attribute"; +/** + * A hook for the output panel. + */ + +const OutputPanel = { + mounted() { + this.props = this.getProps(); + this.isDragging = false; + this.draggedEl = null; + this.srcDropzoneRow = null; + this.srcDropzoneEl = null; + this.dragLabel = this.createDragLabel(); + + this.el.addEventListener("dragstart", (event) => { + if (!this.isDragging) { + this.isDragging = true; + this.draggedEl = event.target.closest(`[data-el-output-panel-item]`); + const elRowIndex = getAttributeOrThrow( + event.target, + "data-row-index", + parseInteger + ); + const elColIndex = getAttributeOrThrow( + event.target, + "data-col-index", + parseInteger + ); + this.srcDropzoneRow = document.querySelector( + `[data-el-output-panel-row][data-row-index="${elRowIndex}"]` + ); + this.srcDropzoneEl = document.getElementById( + `dropzone-row-${elRowIndex}-col-${elColIndex}` + ); + this.el.setAttribute("data-js-dragging", ""); + console.log(this.srcDropzoneRow); + console.log(this.srcDropzoneEl); + this.srcDropzoneRow.setAttribute("data-js-dragging-source", ""); + this.srcDropzoneEl.setAttribute("data-js-dragging-source", ""); + + event.dataTransfer.setDragImage(this.dragLabel, 10, 10); + } + }); + + this.el.addEventListener("dragend", (event) => { + this.stopDragging(); + }); + + this.el.addEventListener("dragover", (event) => { + event.stopPropagation(); + event.preventDefault(); + }); + + this.el.addEventListener("drop", (event) => { + event.stopPropagation(); + event.preventDefault(); + + const dstEl = event.target.closest(`[phx-hook="OutputPanelDropzone"]`); + + const srcEl = this.draggedEl; + + if (dstEl && srcEl) { + const cellId = getAttributeOrThrow(srcEl, "data-cell-id"); + const srcRow = getAttributeOrThrow( + srcEl, + "data-row-index", + parseInteger + ); + const srcCol = getAttributeOrThrow( + srcEl, + "data-col-index", + parseInteger + ); + let dstRow = getAttributeOrThrow(dstEl, "data-row-index", parseInteger); + let dstCol = getAttributeOrDefault( + dstEl, + "data-col-index", + null, + parseInteger + ); + + if (dstCol !== null) { + this.pushEventTo(this.props.phxTarget, "handle_move_item", { + cell_id: cellId, + row_index: dstRow, + col_index: dstCol, + }); + } else { + this.pushEventTo( + this.props.phxTarget, + "handle_move_item_to_new_row", + { + cell_id: cellId, + row_index: dstRow, + } + ); + } + } + this.stopDragging(); + }); + }, + updated() { + this.props = this.getProps(); + }, + destroyed() { + document.body.removeChild(this.dragLabel); + }, + getProps() { + return { + phxTarget: getAttributeOrThrow(this.el, "data-phx-target", parseInteger), + }; + }, + stopDragging() { + if (this.isDragging) { + this.isDragging = false; + this.el.removeAttribute("data-js-dragging"); + this.srcDropzoneRow.removeAttribute("data-js-dragging-source"); + this.srcDropzoneEl.removeAttribute("data-js-dragging-source"); + } + }, + createDragLabel() { + const elem = document.createElement("div"); + elem.classList.add("z-50", "w-24", "h-8", "bg-blue-900", "rounded"); + elem.style.position = "fixed"; + elem.style.left = "-100%"; + + // the element must be in the DOM to use it with setDragItem + document.body.appendChild(elem); + return elem; + }, +}; + +export default OutputPanel; diff --git a/assets/js/hooks/output_panel_dropzone.js b/assets/js/hooks/output_panel_dropzone.js new file mode 100644 index 00000000000..4c15e63ddfa --- /dev/null +++ b/assets/js/hooks/output_panel_dropzone.js @@ -0,0 +1,24 @@ +const DRAGGING_ATTR = "data-js-dragging"; + +/** + * A hook used to highlight drop zone when dragging an item in the output panel over a valid area. + */ +const OutputPanelDropzone = { + mounted() { + this.el.addEventListener("dragenter", (event) => { + this.el.setAttribute(DRAGGING_ATTR, ""); + }); + + this.el.addEventListener("dragleave", (event) => { + if (!this.el.contains(event.relatedTarget)) { + this.el.removeAttribute(DRAGGING_ATTR); + } + }); + + this.el.addEventListener("drop", (event) => { + this.el.removeAttribute(DRAGGING_ATTR); + }); + }, +}; + +export default OutputPanelDropzone; diff --git a/assets/js/hooks/output_panel_item.js b/assets/js/hooks/output_panel_item.js new file mode 100644 index 00000000000..f308ebca44a --- /dev/null +++ b/assets/js/hooks/output_panel_item.js @@ -0,0 +1,24 @@ +/** + * A hook for an output item in the Output Panel. + */ +const OutputPanelItem = { + mounted() { + // since the :hover pseudo-class in CSS is not directly triggerable by JavaScript, + // we need this to show options menu for iframes on hover. + this.el.addEventListener("mouseenter", (event) => { + const optionsEl = this.el.querySelector( + "[data-el-output-panel-item-options]" + ); + optionsEl && optionsEl.classList.remove("hidden"); + }); + + this.el.addEventListener("mouseleave", (event) => { + const optionsEl = this.el.querySelector( + "[data-el-output-panel-item-options]" + ); + optionsEl && optionsEl.classList.add("hidden"); + }); + }, +}; + +export default OutputPanelItem; diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 34b41b99161..85ec2a4a732 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)); @@ -88,6 +92,16 @@ const Session = { this._handleDocumentFocus = this.handleDocumentFocus.bind(this); this._handleDocumentClick = this.handleDocumentClick.bind(this); + this.el.addEventListener("output_panel:activate", (event) => { + if (this.el.getAttribute("data-js-view") != "output-panel-popped-out") { + this.el.setAttribute("data-js-view", "output-panel"); + } + }); + + this.el.addEventListener("output_panel:popin", (event) => { + this.handleOutputPanelPopinClick(); + }); + document.addEventListener("keydown", this._handleDocumentKeyDown, true); document.addEventListener("mousedown", this._handleDocumentMouseDown); // Note: the focus event doesn't bubble, so we register for the capture phase @@ -135,9 +149,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", @@ -668,6 +702,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. @@ -1096,22 +1150,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, @@ -1157,6 +1224,14 @@ const Session = { } }, + deactivateView() { + this.unsetView(); + + if (this.view === "custom") { + this.unsubscribeCustomViewFromSettings(); + } + }, + setView(view, options) { this.view = view; this.viewOptions = options; @@ -1513,6 +1588,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/notebook.ex b/lib/livebook/notebook.ex index 658de89a54a..e17ca2bb244 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -27,10 +27,11 @@ defmodule Livebook.Notebook do :hub_secret_names, :file_entries, :quarantine_file_entry_names, - :teams_enabled + :teams_enabled, + :output_panel ] - alias Livebook.Notebook.{Section, Cell, AppSettings} + alias Livebook.Notebook.{Section, Cell, AppSettings, OutputPanel} alias Livebook.Utils.Graph import Livebook.Utils, only: [access_by_id: 1] @@ -48,7 +49,8 @@ defmodule Livebook.Notebook do hub_secret_names: list(String.t()), file_entries: list(file_entry()), quarantine_file_entry_names: MapSet.new(String.t()), - teams_enabled: boolean() + teams_enabled: boolean(), + output_panel: %{rows: list(output_panel_row())} } @typedoc """ @@ -93,6 +95,15 @@ defmodule Livebook.Notebook do url: String.t() } + @type output_panel_row :: %{items: list(output_panel_item)} + + @type output_panel_item :: %{ + cell_id: Livebook.Utils.id(), + width: output_width() + } + + @type output_width :: 0..100 + @doc """ Returns a blank notebook. """ @@ -112,11 +123,68 @@ defmodule Livebook.Notebook do hub_secret_names: [], file_entries: [], quarantine_file_entry_names: MapSet.new(), - teams_enabled: false + teams_enabled: false, + output_panel: OutputPanel.new() } |> put_setup_cell(Cell.new(:code)) end + @doc """ + Adds the output of the given cell_id to the end of the output panel. + """ + @spec add_output_to_output_panel(t(), Cell.id()) :: t() + def add_output_to_output_panel(notebook, cell_id) do + item = OutputPanel.new_item(cell_id) + output_panel = OutputPanel.move_item_to_new_row(notebook.output_panel, item) + %{notebook | output_panel: output_panel} + end + + @doc """ + Moves the output to a new row in the output panel. + """ + @spec move_output_to_new_row(t(), Cell.id(), integer()) :: t() + def move_output_to_new_row(notebook, cell_id, row_index) do + item = OutputPanel.get_item_by_cell_id(notebook.output_panel, cell_id) + output_panel = OutputPanel.move_item_to_new_row(notebook.output_panel, item, row_index) + %{notebook | output_panel: output_panel} + end + + @doc """ + Move output to new location in output panel. + """ + @spec move_output_to_new_location(t(), Cell.id(), integer(), integer()) :: t() + def move_output_to_new_location(notebook, cell_id, row_index, col_index) do + item = OutputPanel.get_item_by_cell_id(notebook.output_panel, cell_id) + output_panel = OutputPanel.move_item(notebook.output_panel, item, {row_index, col_index}) + %{notebook | output_panel: output_panel} + end + + @doc """ + Removes the output of the given cell_id from the output panel. + """ + @spec remove_output_from_output_panel(t(), Cell.id()) :: t() + def remove_output_from_output_panel(notebook, cell_id) do + item = OutputPanel.get_item_by_cell_id(notebook.output_panel, cell_id) + output_panel = OutputPanel.remove_item(notebook.output_panel, item) + %{notebook | output_panel: output_panel} + end + + @doc """ + Returns all output ids already added to the output panel. + """ + @spec output_panel_ids(t()) :: list(Cell.id()) + def output_panel_ids(notebook) do + get_in(notebook, [ + Access.key(:output_panel), + Access.key(:rows), + Access.all(), + Access.key(:items), + Access.all(), + Access.key(:cell_id) + ]) + |> List.flatten() + end + @doc """ Sets the given cell as the setup cell. """ diff --git a/lib/livebook/notebook/app_settings.ex b/lib/livebook/notebook/app_settings.ex index 434e195cb86..846ad1d2913 100644 --- a/lib/livebook/notebook/app_settings.ex +++ b/lib/livebook/notebook/app_settings.ex @@ -20,7 +20,7 @@ defmodule Livebook.Notebook.AppSettings do } @type access_type :: :public | :protected - @type output_type :: :all | :rich + @type output_type :: :all | :rich | :output_panel @primary_key false embedded_schema do @@ -32,7 +32,7 @@ defmodule Livebook.Notebook.AppSettings do field :access_type, Ecto.Enum, values: [:public, :protected] field :password, :string field :show_source, :boolean - field :output_type, Ecto.Enum, values: [:all, :rich] + field :output_type, Ecto.Enum, values: [:all, :rich, :output_panel] end @doc """ diff --git a/lib/livebook/notebook/output_panel.ex b/lib/livebook/notebook/output_panel.ex new file mode 100644 index 00000000000..d8482597857 --- /dev/null +++ b/lib/livebook/notebook/output_panel.ex @@ -0,0 +1,196 @@ +defmodule Livebook.Notebook.OutputPanel do + @moduledoc false + + # Data structure representing Output Panel in a notebook. + # + # There is only one Output Panel for each notebook and it is + # a view to reorganize cell output. + + defstruct [:rows] + + alias Livebook.Notebook.Cell + import Livebook.Utils, only: [access_by_id: 2] + + @type t :: %__MODULE__{ + rows: list(row) + } + + @type row :: %{items: list(item)} + @type item :: %{cell_id: Cell.id(), width: 0..100} + @type item_position :: {integer(), integer()} + + @doc """ + Returns an empty Output Panel. + """ + @spec new() :: t() + def new() do + %__MODULE__{ + rows: [] + } + end + + @doc """ + Creates a new item. + """ + @spec new_item(Cell.id(), integer()) :: item() + def new_item(cell_id, width \\ 100) do + %{cell_id: cell_id, width: width} + end + + @doc """ + Returns the `item` of the given cell_id. + """ + @spec get_item_by_cell_id(t(), Cell.id()) :: item() | nil + def get_item_by_cell_id(panel, cell_id) do + get_in(panel, [ + Access.key(:rows), + Access.all(), + Access.key(:items), + access_by_id(cell_id, :cell_id) + ]) + |> Enum.reject(&is_nil/1) + |> List.first() + end + + @doc """ + Inserts a new row at the given index and moves the given item to it. + 0 is the first row, -1 is the last row. + """ + @spec move_item_to_new_row(t(), item(), integer()) :: t() + def move_item_to_new_row(panel, item, row_index \\ -1) do + old_position = get_item_position(panel, item) + {panel, row_removed?} = remove_item_at(panel, old_position) + + update_in(panel.rows, fn rows -> + item = set_item_width(item, 100) + row = %{items: [item]} + List.insert_at(rows, update_row_index(row_index, old_position, row_removed?), row) + end) + end + + defp update_row_index(row_index, nil, _), do: row_index + + defp update_row_index(row_index, {old_row_index, _}, true) when row_index > old_row_index, + do: row_index - 1 + + defp update_row_index(row_index, _, _), do: row_index + + @doc """ + Moves the item to the given position and updates the width of + the influenced items. + If the item is already in the output panel, it gets removed. + If the position is invalid, the panel isn't updated. + """ + @spec move_item(t(), item(), item_position()) :: t() + def move_item(panel, item, position) do + old_position = get_item_position(panel, item) + + if valid_position?(panel, position) && position != old_position do + {panel, row_removed?} = remove_item_at(panel, old_position) + insert_item(panel, item, update_position(position, old_position, row_removed?)) + else + panel + end + end + + defp update_position(position, nil, _), do: position + + defp update_position({same_row, col_index}, {same_row, old_col_index}, _) + when col_index > old_col_index, + do: {same_row, col_index - 1} + + defp update_position({row_index, col_index}, {old_row_index, _}, true) + when row_index > old_row_index, + do: {row_index - 1, col_index} + + defp update_position(position, _, _), do: position + + @doc """ + Removes the item from the output panel. + If it's the only item in the row, the whole row is removed. + Otherwise, the space left behind is spread out amoung the other items in the row. + """ + @spec remove_item(t(), item()) :: t() + def remove_item(panel, item) do + position = get_item_position(panel, item) + {panel, _} = remove_item_at(panel, position) + panel + end + + @doc """ + Returns the position of the given item as a tuple {row, column}. + When not found it returns nil. + Only returns the first occurance of the item. Since items are unique + this shouldn't be a problem. + """ + @spec get_item_position(t(), item()) :: item_position() | nil + def get_item_position(panel, item) do + find_position_in_rows(panel.rows, item[:cell_id], 0) + end + + defp find_position_in_rows(_rows, nil, _row_index), do: nil + defp find_position_in_rows([], _cell_id, _row_index), do: nil + + defp find_position_in_rows([row | rows], cell_id, row_index) do + case find_position_in_items(row.items, cell_id, 0) do + nil -> + find_position_in_rows(rows, cell_id, row_index + 1) + + column_index -> + {row_index, column_index} + end + end + + defp find_position_in_items([], _cell_id, _column_index), do: nil + + defp find_position_in_items([item | items], cell_id, column_index) do + if item.cell_id == cell_id, + do: column_index, + else: find_position_in_items(items, cell_id, column_index + 1) + end + + defp valid_position?(panel, {row_index, col_index}) do + row = Enum.at(panel.rows, row_index) + row && col_index <= length(row.items) + end + + defp insert_item(panel, item, {row_index, col_index}) do + update_in(panel, [Access.key(:rows), Access.at(row_index), Access.key(:items)], fn items -> + num_items = length(items) + width = div(100, num_items + 1) + + List.insert_at(items, col_index, item) + |> Enum.map(fn item -> + %{item | width: width} + end) + end) + end + + defp remove_item_at(panel, nil), do: {panel, false} + + defp remove_item_at(panel, {row_index, col_index}) do + row = Enum.at(panel.rows, row_index) + num_items = length(row.items) + + panel = + update_in(panel.rows, fn rows -> + if num_items == 1 do + List.delete_at(rows, row_index) + else + update_in(rows, [Access.at(row_index), Access.key(:items)], fn items -> + {_removed_item, updated_items} = List.pop_at(items, col_index) + + Enum.map(updated_items, fn item -> + %{item | width: div(100, num_items - 1)} + end) + end) + end + end) + + {panel, num_items == 1} + end + + defp set_item_width(item, width) do + %{item | width: width} + end +end diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 9e9745d550e..93397c882d4 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -325,6 +325,38 @@ defmodule Livebook.Session do GenServer.cast(pid, {:set_notebook_attributes, self(), attrs}) end + @doc """ + Sends add output to output panel request to the server. + """ + @spec add_output_to_output_panel(pid(), Cell.id()) :: :ok + def add_output_to_output_panel(pid, cell_id) do + GenServer.cast(pid, {:add_output_to_output_panel, self(), cell_id}) + end + + @doc """ + Sends remove output from output panel request to the server. + """ + @spec remove_output_from_output_panel(pid(), Cell.id()) :: :ok + def remove_output_from_output_panel(pid, cell_id) do + GenServer.cast(pid, {:remove_output_from_output_panel, self(), cell_id}) + end + + @doc """ + Sends move output to new location request. + """ + @spec move_output_to_new_location(pid(), Cell.id(), integer(), integer()) :: :ok + def move_output_to_new_location(pid, cell_id, row_index, col_index) do + GenServer.cast(pid, {:move_output_to_new_location, self(), cell_id, row_index, col_index}) + end + + @doc """ + Sends move output to new row request to the server. + """ + @spec move_output_to_new_row(pid(), Cell.id(), integer()) :: :ok + def move_output_to_new_row(pid, cell_id, row_index) do + GenServer.cast(pid, {:move_output_to_new_row, self(), cell_id, row_index}) + end + @doc """ Sends section insertion request to the server. """ @@ -1052,6 +1084,33 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end + def handle_cast({:add_output_to_output_panel, client_pid, cell_id}, state) do + client_id = client_id(state, client_pid) + operation = {:add_output_to_output_panel, client_id, cell_id} + {:noreply, handle_operation(state, operation)} + end + + def handle_cast({:remove_output_from_output_panel, client_pid, cell_id}, state) do + client_id = client_id(state, client_pid) + operation = {:remove_output_from_output_panel, client_id, cell_id} + {:noreply, handle_operation(state, operation)} + end + + def handle_cast( + {:move_output_to_new_location, client_pid, cell_id, row_index, col_index}, + state + ) do + client_id = client_id(state, client_pid) + operation = {:move_output_to_new_location, client_id, cell_id, row_index, col_index} + {:noreply, handle_operation(state, operation)} + end + + def handle_cast({:move_output_to_new_row, client_pid, cell_id, row_index}, state) do + client_id = client_id(state, client_pid) + operation = {:move_output_to_new_row, client_id, cell_id, row_index} + {:noreply, handle_operation(state, operation)} + end + def handle_cast({:insert_section, client_pid, index}, state) do client_id = client_id(state, client_pid) # Include new id in the operation, so it's reproducible diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index cf962539dd0..704c7bd508a 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -175,6 +175,10 @@ defmodule Livebook.Session.Data do @type operation :: {:set_notebook_attributes, client_id(), map()} + | {:add_output_to_output_panel, client_id(), Cell.id()} + | {:remove_output_from_output_panel, client_id(), Cell.id()} + | {:move_output_to_new_location, client_id(), Cell.id(), integer(), integer()} + | {:move_output_to_new_row, client_id(), Cell.id(), integer()} | {:insert_section, client_id(), index(), Section.id()} | {:insert_section_into, client_id(), Section.id(), index(), Section.id()} | {:set_section_parent, client_id(), Section.id(), parent_id :: Section.id()} @@ -380,6 +384,45 @@ defmodule Livebook.Session.Data do end end + def apply_operation(data, {:add_output_to_output_panel, _client_id, cell_id}) do + with false <- cell_id in Notebook.output_panel_ids(data.notebook) do + data + |> with_actions() + |> add_output_to_output_panel(cell_id) + |> set_dirty() + |> wrap_ok() + else + _ -> :error + end + end + + def apply_operation(data, {:remove_output_from_output_panel, _client_id, cell_id}) do + data + |> with_actions() + |> remove_output_from_output_panel(cell_id) + |> set_dirty() + |> wrap_ok() + end + + def apply_operation( + data, + {:move_output_to_new_location, _client_id, cell_id, row_index, col_index} + ) do + data + |> with_actions() + |> move_output_to_new_location(cell_id, row_index, col_index) + |> set_dirty() + |> wrap_ok() + end + + def apply_operation(data, {:move_output_to_new_row, _client_id, cell_id, row_index}) do + data + |> with_actions() + |> move_output_to_new_row(cell_id, row_index) + |> set_dirty() + |> wrap_ok() + end + def apply_operation(data, {:insert_section, _client_id, index, id}) do section = %{Section.new() | id: id} @@ -1001,6 +1044,28 @@ defmodule Livebook.Session.Data do |> set!(notebook: Map.merge(data.notebook, attrs)) end + defp add_output_to_output_panel({data, _} = data_actions, cell_id) do + data_actions + |> set!(notebook: Notebook.add_output_to_output_panel(data.notebook, cell_id)) + end + + defp remove_output_from_output_panel({data, _} = data_actions, cell_id) do + data_actions + |> set!(notebook: Notebook.remove_output_from_output_panel(data.notebook, cell_id)) + end + + defp move_output_to_new_location({data, _} = data_actions, cell_id, row_index, col_index) do + data_actions + |> set!( + notebook: Notebook.move_output_to_new_location(data.notebook, cell_id, row_index, col_index) + ) + end + + defp move_output_to_new_row({data, _} = data_actions, cell_id, row_index) do + data_actions + |> set!(notebook: Notebook.move_output_to_new_row(data.notebook, cell_id, row_index)) + end + defp insert_section({data, _} = data_actions, index, section) do data_actions |> set!( @@ -1096,6 +1161,7 @@ defmodule Livebook.Session.Data do ] ) |> delete_cell_info(cell) + |> remove_output_from_output_panel(cell.id) end defp pristine_evaluation?(eval_info) do diff --git a/lib/livebook/utils.ex b/lib/livebook/utils.ex index cd119626ba4..0a77a901db9 100644 --- a/lib/livebook/utils.ex +++ b/lib/livebook/utils.ex @@ -118,17 +118,17 @@ defmodule Livebook.Utils do ** (RuntimeError) Livebook.Utils.access_by_id/1 expected a list, got: %{} """ - @spec access_by_id(term()) :: + @spec access_by_id(term(), atom()) :: Access.access_fun(data :: struct() | map(), current_value :: term()) - def access_by_id(id) do + def access_by_id(id, field \\ :id) do fn :get, data, next when is_list(data) -> data - |> Enum.find(fn item -> item.id == id end) + |> Enum.find(fn item -> Map.fetch!(item, field) == id end) |> next.() :get_and_update, data, next when is_list(data) -> - case Enum.split_while(data, fn item -> item.id != id end) do + case Enum.split_while(data, fn item -> Map.fetch!(item, field) != id end) do {prev, [item | cons]} -> case next.(item) do {get, update} -> diff --git a/lib/livebook_web/components/form_components.ex b/lib/livebook_web/components/form_components.ex index 0a638c65c5b..af2d5830f4b 100644 --- a/lib/livebook_web/components/form_components.ex +++ b/lib/livebook_web/components/form_components.ex @@ -583,7 +583,7 @@ defmodule LivebookWeb.FormComponents do <.live_file_input upload={@upload} class="hidden" />