diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index a056fb7dbfc..df4a98e3e55 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -286,8 +286,56 @@ solely client-side operations. @apply hidden; } +[data-el-session][data-js-view^="canvas"] + ~ #app-settings-modal + [data-el-app-settings-enable-canvas-button] { + @apply hidden; +} + +[data-el-session]:not([data-js-view="canvas-popped-out"]) + ~ #app-settings-modal + [data-el-app-settings-popin-canvas-button] { + @apply hidden; +} + /* === Views === */ +[data-el-session]:not([data-js-view]) [data-el-view-deactivate-button] { + @apply hidden; +} + +[data-el-session][data-js-view] [data-el-view-toggle="canvas"], +[data-el-session][data-js-view] [data-el-view-toggle="code-zen"], +[data-el-session][data-js-view] [data-el-view-toggle="presentation"] { + @apply pointer-events-none; +} + +[data-el-session]:not([data-js-view="canvas"]) [data-el-canvas], +[data-el-session]:not([data-js-view^="canvas"]) [data-el-canvas-menu], +[data-el-session]:not([data-js-view="canvas-popped-out"]) + [data-el-canvas-popin-button], +[data-el-session]:not([data-js-view="canvas"]) [data-el-canvas-popout-button], +[data-el-session]:not([data-js-view="canvas"]) [data-el-canvas-close-button] { + @apply hidden; +} + +[data-el-session][data-js-view="canvas"] [data-el-notebook-indicators] { + @apply fixed bottom-[0.4rem] right-1/2; +} + +[data-el-session][data-js-view^="canvas"] [data-el-view-toggle="canvas"], +[data-el-session][data-js-view^="canvas"] + [data-el-view-canvas-poppedout-button] { + @apply text-green-bright-400; +} + +[data-el-session]:not([data-js-view="canvas-popped-out"]) + [data-el-view-canvas-poppedout-button], +[data-el-session][data-js-view="canvas-popped-out"] + [data-el-view-toggle="canvas"] { + @apply hidden; +} + [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"] @@ -329,12 +377,20 @@ solely client-side operations. @apply text-green-bright-400; } -[data-el-session]:is([data-js-view="code-zen"], [data-js-view="presentation"]) +[data-el-session]:is( + [data-js-view^="canvas"], + [data-js-view="code-zen"], + [data-js-view="presentation"] + ) [data-el-views-disabled] { @apply hidden; } -[data-el-session]:not([data-js-view="code-zen"], [data-js-view="presentation"]) +[data-el-session]:not( + [data-js-view=^="canvas"], + [data-js-view="code-zen"], + [data-js-view="presentation"] + ) [data-el-views-enabled] { @apply hidden; } diff --git a/assets/js/hooks/app_canvas.js b/assets/js/hooks/app_canvas.js new file mode 100644 index 00000000000..0f72887e63c --- /dev/null +++ b/assets/js/hooks/app_canvas.js @@ -0,0 +1,36 @@ +import "gridstack/dist/gridstack.min.css"; +import { GridStack } from "gridstack"; + +/** + * A hook for creating app dashboard. + */ +const AppCanvas = { + mounted() { + const self = this; + + const options = { + staticGrid: true, + float: true, + margin: 0, + cellHeight: "4rem", + }; + + this.grid = GridStack.init(options, this.el); + + this.handleEvent("init", ({ payload }) => { + const grid_items = Object.entries(payload).map(([id, value]) => ({ + id, + ...value, + })); + this.grid.load(grid_items); + console.log(this.grid.el.children); + Array.from(this.grid.el.children).forEach((item) => { + const output_id = `[id^=outputs-${item.id}]`; + const output_el = document.querySelector(output_id); + item.firstChild.appendChild(output_el); + }); + }); + }, +}; + +export default AppCanvas; diff --git a/assets/js/hooks/canvas.js b/assets/js/hooks/canvas.js new file mode 100644 index 00000000000..c89bc290b41 --- /dev/null +++ b/assets/js/hooks/canvas.js @@ -0,0 +1,77 @@ +import { getAttributeOrThrow, parseInteger } from "../lib/attribute"; +import { globalPubSub } from "../lib/pub_sub"; +import "gridstack/dist/gridstack.min.css"; +import { GridStack } from "gridstack"; + +/** + * A hook for creating a canvas with gridstackjs. + */ +const Canvas = { + mounted() { + this.props = this.getProps(); + console.log("Gridstack mounted"); + const self = this; + + const options = { + styleInHead: true, + float: true, + resizable: { handles: "all" }, + margin: 0, + cellHeight: "4rem", + }; + + this.grid = GridStack.init(options, this.el); + + this.handleEvent("reload", ({ payload }) => { + const grid_items = Object.entries(payload).map(([id, value]) => ({ + id, + ...value, + })); + this.grid.load(grid_items); + Array.from(this.grid.el.children).forEach((item) => { + console.log(item.attributes); + const output_id = `[id^=outputs-${item.attributes["gs-id"].value}]`; + const output_el = document.querySelector(output_id); + item.firstChild.appendChild(output_el); + }); + }); + + this.grid.on("change", function (event, items) { + console.log("ITEMS changed: ", items); + let new_items = items.reduce((acc, item) => { + acc[item.id] = { + x: item.x, + y: item.y, + w: item.w, + h: item.h, + }; + return acc; + }, {}); + self.pushEventTo(self.props.phxTarget, "items_changed", new_items); + self.repositionIframe(); + }); + + this.grid.on("removed", (event, items) => { + console.log("REMOVED", event); + }); + + this.grid.on("drag", function (event, item) { + // TODO update iframe position when dragging + //self.repositionIframe(); + }); + }, + updated() { + this.props = this.getProps(); + console.log("Gridstack updated", this.grid); + }, + getProps() { + return { + phxTarget: getAttributeOrThrow(this.el, "data-phx-target", parseInteger), + }; + }, + repositionIframe() { + globalPubSub.broadcast("js_views", { type: "reposition" }); + }, +}; + +export default Canvas; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index f960ce98e75..0548d431d52 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1,5 +1,7 @@ import AppAuth from "./app_auth"; +import AppCanvas from "./app_canvas"; import AudioInput from "./audio_input"; +import Canvas from "./canvas"; import Cell from "./cell"; import CellEditor from "./cell_editor"; import Dropzone from "./dropzone"; @@ -13,6 +15,7 @@ import ImageOutput from "./image_output"; import JSView from "./js_view"; import KeyboardControl from "./keyboard_control"; import MarkdownRenderer from "./markdown_renderer"; +import PopoutWindow from "./popout_window"; import ScrollOnUpdate from "./scroll_on_update"; import Session from "./session"; import TextareaAutosize from "./textarea_autosize"; @@ -24,7 +27,9 @@ import VirtualizedLines from "./virtualized_lines"; export default { AppAuth, + AppCanvas, AudioInput, + Canvas, Cell, CellEditor, Dropzone, @@ -38,6 +43,7 @@ export default { JSView, KeyboardControl, MarkdownRenderer, + PopoutWindow, ScrollOnUpdate, Session, TextareaAutosize, diff --git a/assets/js/hooks/popout_window.js b/assets/js/hooks/popout_window.js new file mode 100644 index 00000000000..a9c730e7b45 --- /dev/null +++ b/assets/js/hooks/popout_window.js @@ -0,0 +1,45 @@ +/** + * A hook for popped out windows. + */ +const PopoutWindow = { + mounted() { + this.props = this.getProps(); + console.log("PopoutWindow mounted"); + const self = this; + + this.handleBeforeUnloadEvent = this.handleBeforeUnloadEvent.bind(this); + window.addEventListener("beforeunload", this.handleBeforeUnloadEvent); + this.getElement("canvas-close-button").addEventListener("click", (event) => + this.handleCanvasCloseClick() + ); + this.getElement("canvas-popin-button").addEventListener("click", (event) => + this.handleCanvasPopinClick() + ); + }, + updated() { + this.props = this.getProps(); + console.log("PopoutWindow updated"); + }, + getProps() { + return {}; + }, + handleBeforeUnloadEvent(event) { + this.sendToParent("canvas_popin_clicked"); + }, + handleCanvasCloseClick() { + window.removeEventListener("beforeunload", this.handleBeforeUnloadEvent); + this.sendToParent("canvas_close_clicked"); + }, + handleCanvasPopinClick() { + window.removeEventListener("beforeunload", this.handleBeforeUnloadEvent); + this.sendToParent("canvas_popin_clicked"); + }, + getElement(name) { + return document.querySelector(`[data-el-${name}]`); + }, + sendToParent(message) { + window.opener.postMessage(message, window.location.origin); + }, +}; + +export default PopoutWindow; diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 74769d4f17d..dce16cf1d24 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -74,6 +74,9 @@ const Session = { this.clientsMap = {}; this.lastLocationReportByClientId = {}; this.followedClientId = null; + this.canvasWindow = null; + + this.handleCanvasWindowMessage = this.handleCanvasWindowMessage.bind(this); setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus)); @@ -92,6 +95,20 @@ const Session = { document.addEventListener("focus", this._handleDocumentFocus, true); document.addEventListener("click", this._handleDocumentClick); + this.el.addEventListener("canvas:show", (event) => { + if (this.el.getAttribute("data-js-view") != "canvas-popped-out") { + this.el.setAttribute("data-js-view", "canvas"); + } + }); + + this.el.addEventListener("canvas:popin", (event) => { + this.handleCanvasPopinClick(); + }); + + this.el.addEventListener("canvas:enable", (event) => { + this.el.setAttribute("data-js-view", "canvas"); + }); + this.getElement("sections-list").addEventListener("click", (event) => { this.handleSectionsListClick(event); this.handleCellIndicatorsClick(event); @@ -137,6 +154,28 @@ const Session = { this.handleViewsClick(event); }); + this.getElement("view-deactivate-button").addEventListener( + "click", + (event) => { + this.handleViewDeactivateClick(); + } + ); + + this.getElement("canvas-close-button").addEventListener("click", (event) => + this.handleCanvasCloseClick() + ); + + this.getElement("canvas-popout-button").addEventListener("click", (event) => + this.handleCanvasPopoutClick() + ); + + this.getElement("view-canvas-poppedout-button").addEventListener( + "click", + (event) => { + this.handleCanvasPopinClick(); + } + ); + this.getElement("section-toggle-collapse-all-button").addEventListener( "click", (event) => this.toggleCollapseAllSections() @@ -417,9 +456,9 @@ const Session = { } else if (keyBuffer.tryMatch(["M"])) { !this.isViewCodeZen() && this.insertCellAboveFocused("markdown"); } else if (keyBuffer.tryMatch(["v", "z"])) { - this.toggleView("code-zen"); + this.activateView("code-zen"); } else if (keyBuffer.tryMatch(["v", "p"])) { - this.toggleView("presentation"); + this.activateView("presentation"); } else if (keyBuffer.tryMatch(["c"])) { !this.isViewCodeZen() && this.toggleCollapseSection(); } else if (keyBuffer.tryMatch(["C"])) { @@ -649,6 +688,26 @@ const Session = { } }, + handleCanvasCloseClick() { + this.closeCanvasWindow(); + this.el.removeAttribute("data-js-view"); + }, + + handleCanvasPopoutClick() { + this.canvasWindow = window.open( + window.location.pathname + `/popout-window?type=canvas`, + "_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.handleCanvasWindowMessage); + this.el.setAttribute("data-js-view", "canvas-popped-out"); + }, + + handleCanvasPopinClick() { + this.closeCanvasWindow(); + this.el.setAttribute("data-js-view", "canvas"); + }, + /** * Focuses cell or any other element based on the current * URL and hook attributes. @@ -974,18 +1033,13 @@ const Session = { if (button) { const view = button.getAttribute("data-el-view-toggle"); - this.toggleView(view); + this.activateView(view); } }, - 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); - } + activateView(view) { + this.view = view; + this.el.setAttribute("data-js-view", view); // If nothing is focused, use the first cell in the viewport const focusedId = this.focusedId || this.nearbyFocusableId(null, 0); @@ -1003,6 +1057,12 @@ const Session = { } }, + handleViewDeactivateClick() { + this.closeCanvasWindow(); + this.view = null; + this.el.removeAttribute("data-js-view"); + }, + toggleCollapseSection() { if (this.focusedId) { const sectionId = this.getSectionIdByFocusableId(this.focusedId); @@ -1158,11 +1218,13 @@ const Session = { // Session event handlers handleSessionEvent(event) { - if (event.type === "cursor_selection_changed") { - this.sendLocationReport({ - focusableId: event.focusableId, - selection: event.selection, - }); + switch (event.type) { + case "cursor_selection_changed": + this.sendLocationReport({ + focusableId: event.focusableId, + selection: event.selection, + }); + break; } }, @@ -1336,6 +1398,10 @@ const Session = { return this.el.querySelector(`[data-el-${name}]`); }, + isViewCanvas() { + return this.view.startsWith("canvas"); + }, + isViewCodeZen() { return this.view === "code-zen"; }, @@ -1343,6 +1409,24 @@ const Session = { isViewPresentation() { return this.view === "presentation"; }, + + closeCanvasWindow() { + window.removeEventListener("message", this.handleCanvasWindowMessage); + this.canvasWindow && this.canvasWindow.close(); + this.canvasWindow = null; + }, + + handleCanvasWindowMessage(event) { + if (event.origin != window.location.origin) return; + switch (event.data) { + case "canvas_popin_clicked": + this.handleCanvasPopinClick(); + break; + case "canvas_close_clicked": + this.handleCanvasCloseClick(); + break; + } + }, }; /** diff --git a/assets/package-lock.json b/assets/package-lock.json index 1c84d3dbf2a..f6b56f15158 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -10,6 +10,7 @@ "@fontsource/red-hat-text": "^5.0.1", "@picmo/popup-picker": "^5.7.6", "crypto-js": "^4.0.0", + "gridstack": "^8.3.0", "hast-util-to-text": "^3.1.1", "hyperlist": "^1.0.0", "jest": "^29.1.2", @@ -4783,6 +4784,21 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, + "node_modules/gridstack": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-8.3.0.tgz", + "integrity": "sha512-RcL2xskAYKOpakvpSwHdKheG7C7YgNY7777C5m+T1JMjSgcmEc3qPBM573l0NuyjMz4Errx1/3p+rMgUfF4+mw==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.me/alaind831" + }, + { + "type": "venmo", + "url": "https://www.venmo.com/adumesny" + } + ] + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -13177,6 +13193,11 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, + "gridstack": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-8.3.0.tgz", + "integrity": "sha512-RcL2xskAYKOpakvpSwHdKheG7C7YgNY7777C5m+T1JMjSgcmEc3qPBM573l0NuyjMz4Errx1/3p+rMgUfF4+mw==" + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", diff --git a/assets/package.json b/assets/package.json index 8946d2f493e..9eea3687253 100644 --- a/assets/package.json +++ b/assets/package.json @@ -14,6 +14,7 @@ "@fontsource/red-hat-text": "^5.0.1", "@picmo/popup-picker": "^5.7.6", "crypto-js": "^4.0.0", + "gridstack": "^8.3.0", "hast-util-to-text": "^3.1.1", "hyperlist": "^1.0.0", "jest": "^29.1.2", diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex index 88282c7b9ea..abafcf16809 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -101,6 +101,57 @@ defmodule Livebook.Notebook do put_in(notebook.setup_section.cells, [%{cell | id: Cell.setup_cell_id()}]) end + @doc """ + TODO + """ + @spec move_output_to_canvas(t(), Cell.t(), Section.t()) :: t() + def move_output_to_canvas(notebook, cell, section) do + notebook + |> update_in( + [ + Access.key(:sections), + access_by_id(section.id), + Access.key(:cells), + access_by_id(cell.id) + ], + fn cell -> + %{cell | output_location: %{x: 0, y: 0, w: 4, h: 2}} + end + ) + end + + @doc """ + TODO + """ + @spec move_output_to_notebook(t(), Cell.t(), Section.t()) :: t() + def move_output_to_notebook(notebook, cell, section) do + notebook + |> update_in( + [ + Access.key(:sections), + access_by_id(section.id), + Access.key(:cells), + access_by_id(cell.id) + ], + fn cell -> + %{cell | output_location: nil} + end + ) + end + + @doc """ + TODO + """ + @spec update_canvas(t(), list(any())) :: t() + def update_canvas(notebook, updates) do + for {cell_id, location} <- updates, reduce: notebook do + acc -> + update_cell(acc, cell_id, fn cell -> + %{cell | output_location: location} + end) + end + end + @doc """ Returns the default value of `persist_outputs`. """ diff --git a/lib/livebook/notebook/app_settings.ex b/lib/livebook/notebook/app_settings.ex index f2a05746f35..8e71320deed 100644 --- a/lib/livebook/notebook/app_settings.ex +++ b/lib/livebook/notebook/app_settings.ex @@ -18,7 +18,7 @@ defmodule Livebook.Notebook.AppSettings do } @type access_type :: :public | :protected - @type output_type :: :all | :rich + @type output_type :: :all | :rich | :canvas @primary_key false embedded_schema do @@ -30,7 +30,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, :canvas] end @doc """ diff --git a/lib/livebook/notebook/cell.ex b/lib/livebook/notebook/cell.ex index 909a618002f..504827e1212 100644 --- a/lib/livebook/notebook/cell.ex +++ b/lib/livebook/notebook/cell.ex @@ -18,6 +18,13 @@ defmodule Livebook.Notebook.Cell do @type indexed_output :: {non_neg_integer(), Livebook.Runtime.output()} + @type canvas_location :: %{ + x: non_neg_integer(), + y: non_neg_integer(), + w: non_neg_integer(), + h: non_neg_integer() + } + @doc """ Returns an empty cell of the given type. """ diff --git a/lib/livebook/notebook/cell/code.ex b/lib/livebook/notebook/cell/code.ex index be666c4ab95..ef8b74fec9e 100644 --- a/lib/livebook/notebook/cell/code.ex +++ b/lib/livebook/notebook/cell/code.ex @@ -10,6 +10,7 @@ defmodule Livebook.Notebook.Cell.Code do :id, :source, :outputs, + :output_location, :language, :disable_formatting, :reevaluate_automatically, @@ -23,6 +24,7 @@ defmodule Livebook.Notebook.Cell.Code do id: Cell.id(), source: String.t() | :__pruned__, outputs: list(Cell.indexed_output()), + output_location: Cell.canvas_location() | nil, language: :elixir | :erlang, disable_formatting: boolean(), reevaluate_automatically: boolean(), @@ -38,6 +40,7 @@ defmodule Livebook.Notebook.Cell.Code do id: Utils.random_id(), source: "", outputs: [], + output_location: nil, language: :elixir, disable_formatting: false, reevaluate_automatically: false, diff --git a/lib/livebook/notebook/cell/smart.ex b/lib/livebook/notebook/cell/smart.ex index b5156e2becf..e8a505b63fe 100644 --- a/lib/livebook/notebook/cell/smart.ex +++ b/lib/livebook/notebook/cell/smart.ex @@ -3,7 +3,7 @@ defmodule Livebook.Notebook.Cell.Smart do # A cell with Elixir code that is edited through a dedicated UI. - defstruct [:id, :source, :chunks, :outputs, :kind, :attrs, :js_view, :editor] + defstruct [:id, :source, :chunks, :outputs, :output_location, :kind, :attrs, :js_view, :editor] alias Livebook.Utils alias Livebook.Notebook.Cell @@ -13,6 +13,7 @@ defmodule Livebook.Notebook.Cell.Smart do source: String.t() | :__pruned__, chunks: Livebook.Runtime.chunks() | nil, outputs: list(Cell.indexed_output()), + output_location: Cell.canvas_location() | nil, kind: String.t() | nil, attrs: attrs() | :__pruned__, js_view: Livebook.Runtime.js_view() | nil, @@ -31,6 +32,7 @@ defmodule Livebook.Notebook.Cell.Smart do source: "", chunks: nil, outputs: [], + output_location: nil, kind: nil, attrs: %{}, js_view: nil, diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 6d070079979..4b34148252a 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -302,6 +302,30 @@ defmodule Livebook.Session do GenServer.cast(pid, {:set_notebook_attributes, self(), attrs}) end + @doc """ + TODO + """ + @spec move_output_to_canvas(pid(), Cell.id()) :: :ok + def move_output_to_canvas(pid, cell_id) do + GenServer.cast(pid, {:move_output_to_canvas, self(), cell_id}) + end + + @doc """ + TODO + """ + @spec move_output_to_notebook(pid(), Cell.id()) :: :ok + def move_output_to_notebook(pid, cell_id) do + GenServer.cast(pid, {:move_output_to_notebook, self(), cell_id}) + end + + @doc """ + TODO + """ + @spec update_canvas(pid(), list(any())) :: :ok + def update_canvas(pid, updates) do + GenServer.cast(pid, {:update_canvas, self(), updates}) + end + @doc """ Sends section insertion request to the server. """ @@ -962,6 +986,24 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end + def handle_cast({:move_output_to_canvas, client_pid, cell_id}, state) do + client_id = client_id(state, client_pid) + operation = {:move_output_to_canvas, client_id, cell_id} + {:noreply, handle_operation(state, operation)} + end + + def handle_cast({:move_output_to_notebook, client_pid, cell_id}, state) do + client_id = client_id(state, client_pid) + operation = {:move_output_to_notebook, client_id, cell_id} + {:noreply, handle_operation(state, operation)} + end + + def handle_cast({:update_canvas, client_pid, updates}, state) do + client_id = client_id(state, client_pid) + operation = {:update_canvas, client_id, updates} + {: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 e1e051100e9..3aba27e8223 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -171,6 +171,9 @@ defmodule Livebook.Session.Data do @type operation :: {:set_notebook_attributes, client_id(), map()} + | {:move_output_to_canvas, client_id(), Cell.id()} + | {:move_output_to_notebook, client_id(), Cell.id()} + | {:update_canvas, client_id(), list(any())} | {: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()} @@ -374,6 +377,53 @@ defmodule Livebook.Session.Data do end end + def apply_operation(data, {:move_output_to_canvas, _client_id, cell_id}) do + with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), + false <- Cell.setup?(cell) do + data + |> with_actions() + |> move_output_to_canvas(cell, section) + |> set_dirty() + |> wrap_ok() + else + _ -> :error + end + end + + def apply_operation(data, {:move_output_to_notebook, _client_id, cell_id}) do + with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), + false <- Cell.setup?(cell) do + data + |> with_actions() + |> move_output_to_notebook(cell, section) + |> set_dirty() + |> wrap_ok() + else + _ -> :error + end + end + + def apply_operation(data, {:update_canvas, _client_id, updates}) do + cell_ids = Map.keys(updates) + + cells_with_section = + data.notebook + |> Notebook.cells_with_section() + |> Enum.filter(fn {cell, _section} -> + cell.id in cell_ids + end) + + if cell_ids != [] and length(cell_ids) == length(cells_with_section) do + data + |> with_actions() + |> update_canvas(updates) + |> set_dirty() + |> wrap_ok() + else + :error + end + end + def apply_operation(data, {:insert_section, _client_id, index, id}) do section = %{Section.new() | id: id} @@ -967,6 +1017,21 @@ defmodule Livebook.Session.Data do |> set!(notebook: Map.merge(data.notebook, attrs)) end + defp move_output_to_canvas({data, _} = data_actions, cell, section) do + data_actions + |> set!(notebook: Notebook.move_output_to_canvas(data.notebook, cell, section)) + end + + defp move_output_to_notebook({data, _} = data_actions, cell, section) do + data_actions + |> set!(notebook: Notebook.move_output_to_notebook(data.notebook, cell, section)) + end + + defp update_canvas({data, _} = data_actions, updates) do + data_actions + |> set!(notebook: Notebook.update_canvas(data.notebook, updates)) + end + defp insert_section({data, _} = data_actions, index, section) do data_actions |> set!( diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index 80738b73263..a4161d565c4 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -3,6 +3,9 @@ defmodule LivebookWeb.Helpers do use LivebookWeb, :verified_routes + alias Livebook.Notebook + alias Livebook.Notebook.Cell + @doc """ Determines user platform based on the given *User-Agent* header. """ @@ -82,4 +85,17 @@ defmodule LivebookWeb.Helpers do def format_datetime_relatively(date) do date |> DateTime.to_naive() |> Livebook.Utils.Time.time_ago_in_words() end + + @doc """ + TODO + """ + @spec canvas_outputs(Notebook.t()) :: list({Cell.id(), map()}) + def canvas_outputs(notebook) do + for {cell, _section} <- Notebook.cells_with_section(notebook), + Cell.evaluable?(cell), + cell.output_location != nil, + into: %{} do + {cell.id, cell.output_location} + end + end end diff --git a/lib/livebook_web/live/app_session_live.ex b/lib/livebook_web/live/app_session_live.ex index 656d0d50446..a4da5e9237f 100644 --- a/lib/livebook_web/live/app_session_live.ex +++ b/lib/livebook_web/live/app_session_live.ex @@ -31,6 +31,8 @@ defmodule LivebookWeb.AppSessionLive do {data, nil} end + data_view = data_to_view(data) + {:ok, socket |> assign( @@ -38,9 +40,10 @@ defmodule LivebookWeb.AppSessionLive do session: session, page_title: get_page_title(data.notebook.name), client_id: client_id, - data_view: data_to_view(data) + data_view: data_view ) - |> assign_private(data: data)} + |> assign_private(data: data) + |> push_event("init", %{payload: data_view.canvas_layout})} else {:ok, assign(socket, @@ -94,6 +97,32 @@ defmodule LivebookWeb.AppSessionLive do """ end + def render(%{data_view: %{output_type: :canvas}} = assigns) + when assigns.app_authenticated? do + ~H""" +
+
+
+
+ +
+
+
+
+ """ + end + def render(assigns) when assigns.app_authenticated? do ~H"""
@@ -291,6 +320,7 @@ defmodule LivebookWeb.AppSessionLive do defp data_to_view(data) do %{ notebook_name: data.notebook.name, + canvas_layout: canvas_outputs(data.notebook), output_views: for( {cell_id, output} <- visible_outputs(data.notebook), @@ -300,6 +330,7 @@ defmodule LivebookWeb.AppSessionLive do cell_id: cell_id } ), + output_type: data.notebook.app_settings.output_type, app_status: data.app_data.status, show_source: data.notebook.app_settings.show_source, slug: data.notebook.app_settings.slug, @@ -322,6 +353,7 @@ defmodule LivebookWeb.AppSessionLive do defp filter_outputs(outputs, :all), do: outputs defp filter_outputs(outputs, :rich), do: rich_outputs(outputs) + defp filter_outputs(outputs, :canvas), do: outputs defp rich_outputs(outputs) do for output <- outputs, output = filter_output(output), do: output diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex index 0c219d4b779..3de844283db 100644 --- a/lib/livebook_web/live/output.ex +++ b/lib/livebook_web/live/output.ex @@ -81,7 +81,11 @@ defmodule LivebookWeb.Output do """ end - defp render_output({:js, js_info}, %{id: id, session_id: session_id, client_id: client_id}) do + defp render_output({:js, js_info}, %{ + id: id, + session_id: session_id, + client_id: client_id + }) do live_component(LivebookWeb.JSViewComponent, id: id, js_view: js_info.js_view, diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 2bdc3c9ae6a..209a4b0e976 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -238,171 +238,212 @@ defmodule LivebookWeb.SessionLive do <.runtime_info data_view={@data_view} session={@session} />
-
-
- -
-
-
-
-

<%= @data_view.notebook_name %>

+
+
+
+ +
+
+
+
+

<%= @data_view.notebook_name %>

+
+ + <.menu id="session-menu"> + <:toggle> + + + <.menu_item> + <.link patch={~p"/sessions/#{@session.id}/export/livemd"} role="menuitem"> + <.remix_icon icon="download-2-line" /> + Export + + + <.menu_item> + + + <.menu_item> + + + <.menu_item> + + <.remix_icon icon="dashboard-2-line" /> + See on Dashboard + + + <.menu_item variant={:danger}> + + +
- - <.menu id="session-menu"> +
+ <.menu position={:bottom_left} id="notebook-hub-menu"> + <:toggle> +
+ in + <%= @data_view.hub.hub_emoji %> + <%= @data_view.hub.hub_name %> + <.remix_icon icon="arrow-down-s-line" class="invisible group-hover:visible" /> +
+ + <.menu_item :for={hub <- @saved_hubs}> + + + <.menu_item> + <.link navigate={~p"/hub"} aria-label="Add Hub" role="menuitem"> + <.remix_icon icon="add-line" class="align-middle mr-1" /> Add Hub + + + + +
+ <%= cond do %> + <% @data_view.file == nil -> %> + + + + <% @data_view.file in @starred_files -> %> + + + + <% true -> %> + + + + <% end %> +
+
+
+
+ <.live_component + module={LivebookWeb.SessionLive.CellComponent} + id={@data_view.setup_cell_view.id} + session_id={@session.id} + session_pid={@session.pid} + client_id={@client_id} + runtime={@data_view.runtime} + installing?={@data_view.installing?} + allowed_uri_schemes={@allowed_uri_schemes} + cell_view={@data_view.setup_cell_view} + /> +
+
+
+ +
+ <.live_component + :for={{section_view, index} <- Enum.with_index(@data_view.section_views)} + module={LivebookWeb.SessionLive.SectionComponent} + id={section_view.id} + index={index} + session_id={@session.id} + session_pid={@session.pid} + client_id={@client_id} + runtime={@data_view.runtime} + smart_cell_definitions={@data_view.smart_cell_definitions} + code_block_definitions={@data_view.code_block_definitions} + installing?={@data_view.installing?} + allowed_uri_schemes={@allowed_uri_schemes} + section_view={section_view} + default_language={@data_view.default_language} + /> +
+
+
+
+
+ <.live_component + module={LivebookWeb.SessionLive.CanvasComponent} + id="canvas" + canvas_layout={@data_view.canvas_layout} + session={@session} + client_id={@client_id} + /> +
+
+ <.menu id="canvas-menu" position={:bottom_right}> <:toggle> - <.menu_item> - <.link patch={~p"/sessions/#{@session.id}/export/livemd"} role="menuitem"> - <.remix_icon icon="download-2-line" /> - Export - - - <.menu_item> - - - <.menu_item> - <.menu_item> - - <.remix_icon icon="dashboard-2-line" /> - See on Dashboard - - - <.menu_item variant={:danger}> -
-
- <.menu position={:bottom_left} id="notebook-hub-menu"> - <:toggle> -
- in - <%= @data_view.hub.hub_emoji %> - <%= @data_view.hub.hub_name %> - <.remix_icon icon="arrow-down-s-line" class="invisible group-hover:visible" /> -
- - <.menu_item :for={hub <- @saved_hubs}> - - - <.menu_item> - <.link navigate={~p"/hub"} aria-label="Add Hub" role="menuitem"> - <.remix_icon icon="add-line" class="align-middle mr-1" /> Add Hub - - - - -
- <%= cond do %> - <% @data_view.file == nil -> %> - - - - <% @data_view.file in @starred_files -> %> - - - - <% true -> %> - - - - <% end %> -
-
-
-
- <.live_component - module={LivebookWeb.SessionLive.CellComponent} - id={@data_view.setup_cell_view.id} - session_id={@session.id} - session_pid={@session.pid} - client_id={@client_id} - runtime={@data_view.runtime} - installing?={@data_view.installing?} - allowed_uri_schemes={@allowed_uri_schemes} - cell_view={@data_view.setup_cell_view} - /> -
-
-
- -
- <.live_component - :for={{section_view, index} <- Enum.with_index(@data_view.section_views)} - module={LivebookWeb.SessionLive.SectionComponent} - id={section_view.id} - index={index} - session_id={@session.id} - session_pid={@session.pid} - client_id={@client_id} - runtime={@data_view.runtime} - smart_cell_definitions={@data_view.smart_cell_definitions} - code_block_definitions={@data_view.code_block_definitions} - installing?={@data_view.installing?} - allowed_uri_schemes={@allowed_uri_schemes} - section_view={section_view} - default_language={@data_view.default_language} - /> -
@@ -1078,6 +1119,16 @@ defmodule LivebookWeb.SessionLive do {:noreply, insert_cell_below(socket, params)} end + def handle_event("move_output_to_canvas", %{"cell_id" => cell_id}, socket) do + Session.move_output_to_canvas(socket.assigns.session.pid, cell_id) + {:noreply, socket} + end + + def handle_event("move_output_to_notebook", %{"cell_id" => cell_id}, socket) do + Session.move_output_to_notebook(socket.assigns.session.pid, cell_id) + {:noreply, socket} + end + def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do on_confirm = fn socket -> Session.delete_cell(socket.assigns.session.pid, cell_id) @@ -2277,7 +2328,8 @@ defmodule LivebookWeb.SessionLive do hub_secrets: data.hub_secrets, file_entries: Enum.sort_by(data.notebook.file_entries, & &1.name), app_settings: data.notebook.app_settings, - deployed_app_slug: data.deployed_app_slug + deployed_app_slug: data.deployed_app_slug, + canvas_layout: canvas_outputs(data.notebook) } end @@ -2392,6 +2444,7 @@ defmodule LivebookWeb.SessionLive do defp eval_info_to_view(cell, eval_info, data) do %{ outputs: cell.outputs, + output_location: cell.output_location, validity: eval_info.validity, status: eval_info.status, errored: eval_info.errored, diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index 1b11edf8ba9..8d124ee7e80 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -67,26 +67,43 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do ''' } /> - <.checkbox_field - field={f[:show_source]} - label="Show source" + <.select_field + field={f[:output_type]} + label="Output type" + options={[ + {"All outputs", :all}, + {"Rich outputs only", :rich}, + {"Canvas", :canvas} + ]} help={ ~S''' - When enabled, it makes notebook source - accessible in the app menu. + TODO ''' } /> +
+ <.link + data-el-app-settings-enable-canvas-button + phx-click={JS.dispatch("canvas:enable", to: "[data-el-session]")} + > + <.remix_icon icon="arrow-right-line" class="text-sm" /> + Enable the Canvas view + + <.link + data-el-app-settings-popin-canvas-button + phx-click={JS.dispatch("canvas:popin", to: "[data-el-session]")} + > + <.remix_icon icon="arrow-right-line" /> + Bring Canvas to front + +
<.checkbox_field - field={f[:output_type]} - label="Only render rich outputs" - checked_value="rich" - unchecked_value="all" + field={f[:show_source]} + label="Show source" help={ ~S''' - When enabled, hides simple outputs - and only shows rich elements, such - as inputs, frames, tables, etc. + When enabled, it makes notebook source + accessible in the app menu. ''' } /> diff --git a/lib/livebook_web/live/session_live/canvas_component.ex b/lib/livebook_web/live/session_live/canvas_component.ex new file mode 100644 index 00000000000..01a7f89efd9 --- /dev/null +++ b/lib/livebook_web/live/session_live/canvas_component.ex @@ -0,0 +1,54 @@ +defmodule LivebookWeb.SessionLive.CanvasComponent do + use LivebookWeb, :live_component + + alias Livebook.Session + + @impl true + def mount(socket) do + {:ok, socket} + end + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign( + session: assigns.session, + client_id: assigns.client_id, + canvas_layout: assigns.canvas_layout + ) + |> push_event("reload", %{payload: assigns.canvas_layout}) + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+ """ + end + + @impl true + def handle_event("items_changed", params, socket) do + items = params |> Enum.map(fn {k, v} -> {k, convert(v)} end) |> Enum.into(%{}) + IO.inspect(items, label: "ITEMS CHANGED") + Session.update_canvas(socket.assigns.session.pid, items) + {:noreply, socket} + end + + # TODO rename/optimize + defp convert(value) when is_map(value) do + Enum.into(value, %{}, fn {k, v} -> {String.to_existing_atom(k), v} end) + end + + defp convert(value), do: value +end diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index 0ee73937c56..dae82896759 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -82,6 +82,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do <.move_cell_up_button cell_id={@cell_view.id} /> <.move_cell_down_button cell_id={@cell_view.id} /> <.delete_cell_button cell_id={@cell_view.id} /> + <%= if @cell_view.eval.output_location do %> + <.move_output_to_notebook_button cell_id={@cell_view.id} /> + <% else %> + <.move_output_to_canvas_button cell_id={@cell_view.id} /> + <% end %> <.cell_body> @@ -176,6 +181,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do <.move_cell_up_button cell_id={@cell_view.id} /> <.move_cell_down_button cell_id={@cell_view.id} /> <.delete_cell_button cell_id={@cell_view.id} /> + <%= if @cell_view.eval.output_location do %> + <.move_output_to_notebook_button cell_id={@cell_view.id} /> + <% else %> + <.move_output_to_canvas_button cell_id={@cell_view.id} /> + <% end %> <.cell_body> @@ -476,6 +486,38 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end + defp move_output_to_canvas_button(assigns) do + ~H""" + + + + """ + end + + defp move_output_to_notebook_button(assigns) do + ~H""" + + + + """ + end + defp cell_link_button(assigns) do ~H""" diff --git a/lib/livebook_web/live/session_live/indicators_component.ex b/lib/livebook_web/live/session_live/indicators_component.ex index eb41f4eb56f..e180d81ac75 100644 --- a/lib/livebook_web/live/session_live/indicators_component.ex +++ b/lib/livebook_web/live/session_live/indicators_component.ex @@ -28,11 +28,8 @@ defmodule LivebookWeb.SessionLive.IndicatorsComponent do <.remix_icon icon="menu-unfold-line" />
-
-
+
+
<.view_indicator /> <.persistence_indicator file={@file} @@ -73,6 +70,18 @@ defmodule LivebookWeb.SessionLive.IndicatorsComponent do <.remix_icon icon="layout-5-line" class="text-xl text-green-bright-400" /> + <.menu_item> + + + <.menu_item> + + <.menu_item> + <.menu_item> + +
""" diff --git a/lib/livebook_web/live/session_live/popout_window_live.ex b/lib/livebook_web/live/session_live/popout_window_live.ex new file mode 100644 index 00000000000..9f0cd202f63 --- /dev/null +++ b/lib/livebook_web/live/session_live/popout_window_live.ex @@ -0,0 +1,151 @@ +defmodule LivebookWeb.SessionLive.PopoutWindowLive do + use LivebookWeb, :live_view + + alias Livebook.{Notebook, Session, Sessions} + alias Livebook.Notebook.Cell + + @impl true + def mount(%{"id" => session_id, "type" => type}, _session, socket) do + case Sessions.fetch_session(session_id) do + {:ok, %{pid: session_pid}} -> + {data, client_id} = + if connected?(socket) do + {data, client_id} = + Session.register_client(session_pid, self(), socket.assigns.current_user) + + Session.subscribe(session_id) + + {data, client_id} + else + data = Session.get_data(session_pid) + {data, nil} + end + + session = Session.get_by_pid(session_pid) + + {:ok, + socket + |> assign( + session: session, + client_id: client_id, + type: type, + data_view: data_to_view(data) + ) + |> assign_private(data: data)} + + :error -> + # TODO: handle this error correclty + {:ok, redirect(socket, to: ~p"/")} + end + end + + @impl true + def mount(%{"id" => session_id}, _session, socket) do + {:ok, redirect(socket, to: ~p"/sessions/#{session_id}")} + end + + defp assign_private(socket, assigns) do + Enum.reduce(assigns, socket, fn {key, value}, socket -> + put_in(socket.private[key], value) + end) + end + + defp data_to_view(data) do + %{ + canvas_layout: canvas_outputs(data.notebook), + output_views: + for {cell, _section} <- Notebook.cells_with_section(data.notebook), + Cell.evaluable?(cell), + cell.id != "setup" do + %{ + outputs: cell.outputs, + # input_values: input_values_for_output(cell.outputs, data), + input_values: %{}, + cell_id: cell.id + } + end + } + end + + @impl true + def render(%{type: "canvas"} = assigns) do + ~H""" +
+
+
+
+
+ +
+ <.live_component + module={LivebookWeb.SessionLive.CanvasComponent} + id="canvas" + canvas_layout={@data_view.canvas_layout} + session={@session} + client_id={@client_id} + /> +
+
+ <.menu id="canvas-menu" position={:bottom_right}> + <:toggle> + + + <.menu_item> + + + <.menu_item> + + + +
+
+
+
+
+ """ + end + + @impl true + def handle_info({:operation, operation}, socket) do + socket = + case Session.Data.apply_operation(socket.private.data, operation) do + {:ok, data, _actions} -> + socket + |> assign_private(data: data) + |> assign(data_to_view(data)) + + :error -> + socket + end + + {:noreply, socket} + end + + @impl true + def handle_info(message, socket) do + IO.inspect(message, label: "Not implemented for Popout Window") + {:noreply, socket} + end +end diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index a7c66e4ea31..c69ceb39c0a 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -81,6 +81,7 @@ defmodule LivebookWeb.Router do live "/hub/:id/secrets/edit/:secret_name", Hub.EditLive, :edit_secret, as: :hub live "/sessions/:id", SessionLive, :page + live "/sessions/:id/popout-window", SessionLive.PopoutWindowLive live "/sessions/:id/shortcuts", SessionLive, :shortcuts live "/sessions/:id/secrets", SessionLive, :secrets live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings