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""" +