From 489b6091548dbf9eedbc68d0ea958f3f260291b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Sat, 22 Jul 2023 11:13:06 +0200 Subject: [PATCH] Allow dropping external files into the notebook (#2097) --- assets/css/js_interop.css | 4 + assets/js/hooks/session.js | 135 +++++++++++++----- .../components/form_components.ex | 2 +- lib/livebook_web/live/session_live.ex | 58 +++++++- .../add_file_entry_upload_component.ex | 23 ++- .../live/session_live/files_list_component.ex | 10 ++ .../session_live/insert_file_component.ex | 4 +- .../session_live/insert_image_component.ex | 22 ++- test/livebook_web/live/session_live_test.exs | 41 ++++++ 9 files changed, 250 insertions(+), 49 deletions(-) diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index b53eec2d8..e86daf269 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -77,6 +77,10 @@ solely client-side operations. @apply hidden; } +[data-el-session]:not([data-js-dragging="external"]) [data-el-files-drop-area] { + @apply hidden; +} + [data-el-cell][data-js-focused] { @apply border-blue-300 border-opacity-100; } diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 06acba13b..bc7fa6557 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -713,72 +713,135 @@ const Session = { * Initializes drag and drop event handlers. */ initializeDragAndDrop() { + let isDragging = false; let draggedEl = null; + let files = null; + + const startDragging = (element = null) => { + if (!isDragging) { + isDragging = true; + draggedEl = element; + + const type = element ? "internal" : "external"; + this.el.setAttribute("data-js-dragging", type); + + if (type === "external") { + this.toggleFilesList(true); + } + } + }; + + const stopDragging = () => { + if (isDragging) { + isDragging = false; + this.el.removeAttribute("data-js-dragging"); + } + }; this.el.addEventListener("dragstart", (event) => { - draggedEl = event.target; - this.el.setAttribute("data-js-dragging", ""); + startDragging(event.target); }); - this.el.addEventListener("dragend", (event) => { - this.el.removeAttribute("data-js-dragging"); + this.el.addEventListener("dragenter", (event) => { + startDragging(); }); - this.el.addEventListener("dragover", (event) => { - const dropEl = event.target.closest(`[data-el-insert-drop-area]`); - - if (dropEl) { - event.preventDefault(); + this.el.addEventListener("dragleave", (event) => { + if (!this.el.contains(event.relatedTarget)) { + stopDragging(); } }); - this.el.addEventListener("drop", (event) => { - const dropEl = event.target.closest(`[data-el-insert-drop-area]`); + this.el.addEventListener("dragover", (event) => { + event.stopPropagation(); + event.preventDefault(); + }); - if (draggedEl.matches("[data-el-file-entry]") && dropEl) { - const fileEntryName = draggedEl.getAttribute("data-name"); - const sectionId = dropEl.getAttribute("data-section-id") || null; - const cellId = dropEl.getAttribute("data-cell-id") || null; - this.pushEvent("insert_file", { - file_entry_name: fileEntryName, - section_id: sectionId, - cell_id: cellId, - }); + this.el.addEventListener("drop", (event) => { + event.stopPropagation(); + event.preventDefault(); + + const insertDropEl = event.target.closest(`[data-el-insert-drop-area]`); + const filesDropEl = event.target.closest(`[data-el-files-drop-area]`); + + if (insertDropEl) { + const sectionId = insertDropEl.getAttribute("data-section-id") || null; + const cellId = insertDropEl.getAttribute("data-cell-id") || null; + + if (event.dataTransfer.files.length > 0) { + files = event.dataTransfer.files; + + this.pushEvent("handle_file_drop", { + section_id: sectionId, + cell_id: cellId, + }); + } else if (draggedEl && draggedEl.matches("[data-el-file-entry]")) { + const fileEntryName = draggedEl.getAttribute("data-name"); + + this.pushEvent("insert_file", { + file_entry_name: fileEntryName, + section_id: sectionId, + cell_id: cellId, + }); + } + } else if (filesDropEl) { + if (event.dataTransfer.files.length > 0) { + files = event.dataTransfer.files; + this.pushEvent("handle_file_drop", {}); + } + } + + stopDragging(); + }); + + this.handleEvent("finish_file_drop", (event) => { + const inputEl = document.querySelector( + `#add-file-entry-modal input[type="file"]` + ); + + if (inputEl) { + inputEl.files = files; + inputEl.dispatchEvent(new Event("change", { bubbles: true })); } }); }, // User action handlers (mostly keybindings) - toggleSectionsList() { - this.toggleSidePanelContent("sections-list"); + toggleSectionsList(force = null) { + this.toggleSidePanelContent("sections-list", force); }, - toggleClientsList() { - this.toggleSidePanelContent("clients-list"); + toggleClientsList(force = null) { + this.toggleSidePanelContent("clients-list", force); }, - toggleSecretsList() { - this.toggleSidePanelContent("secrets-list"); + toggleSecretsList(force = null) { + this.toggleSidePanelContent("secrets-list", force); }, - toggleAppInfo() { - this.toggleSidePanelContent("app-info"); + toggleAppInfo(force = null) { + this.toggleSidePanelContent("app-info", force); }, - toggleFilesList() { - this.toggleSidePanelContent("files-list"); + toggleFilesList(force = null) { + this.toggleSidePanelContent("files-list", force); }, - toggleRuntimeInfo() { - this.toggleSidePanelContent("runtime-info"); + toggleRuntimeInfo(force = null) { + this.toggleSidePanelContent("runtime-info", force); }, - toggleSidePanelContent(name) { - if (this.el.getAttribute("data-js-side-panel-content") === name) { - this.el.removeAttribute("data-js-side-panel-content"); - } else { + toggleSidePanelContent(name, force = null) { + const shouldOpen = + force === null + ? this.el.getAttribute("data-js-side-panel-content") !== name + : force; + + if (shouldOpen) { this.el.setAttribute("data-js-side-panel-content", name); + } else { + this.el.removeAttribute("data-js-side-panel-content"); } }, diff --git a/lib/livebook_web/components/form_components.ex b/lib/livebook_web/components/form_components.ex index ef2dee9fb..0a638c65c 100644 --- a/lib/livebook_web/components/form_components.ex +++ b/lib/livebook_web/components/form_components.ex @@ -588,7 +588,7 @@ defmodule LivebookWeb.FormComponents do for={@upload.ref} phx-drop-target={@upload.ref} > - + Click to select a file or drag a local file here diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index de306be39..5cd6ef254 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -965,11 +965,18 @@ defmodule LivebookWeb.SessionLive do {:noreply, handle_relative_path(socket, path, requested_url)} end - def handle_params(%{"tab" => tab}, _url, socket) - when socket.assigns.live_action in [:export, :add_file_entry] do + def handle_params(%{"tab" => tab}, _url, socket) when socket.assigns.live_action == :export do {:noreply, assign(socket, tab: tab)} end + def handle_params(%{"tab" => tab} = params, _url, socket) + when socket.assigns.live_action == :add_file_entry do + file_drop_metadata = + if(params["file_drop"] == "true", do: socket.assigns[:file_drop_metadata]) + + {:noreply, assign(socket, tab: tab, file_drop_metadata: file_drop_metadata)} + end + def handle_params(params, _url, socket) when socket.assigns.live_action == :secrets do socket = @@ -1579,6 +1586,33 @@ defmodule LivebookWeb.SessionLive do {:noreply, socket} end + def handle_event( + "handle_file_drop", + %{"section_id" => section_id, "cell_id" => cell_id}, + socket + ) do + if Livebook.Runtime.connected?(socket.private.data.runtime) do + {:noreply, + socket + |> assign(file_drop_metadata: %{section_id: section_id, cell_id: cell_id}) + |> push_patch( + to: ~p"/sessions/#{socket.assigns.session.id}/add-file/upload?file_drop=true" + ) + |> push_event("finish_file_drop", %{})} + else + reason = "To see the available options, you need a connected runtime." + {:noreply, confirm_setup_default_runtime(socket, reason)} + end + end + + def handle_event("handle_file_drop", %{}, socket) do + {:noreply, + socket + |> assign(file_drop_metadata: nil) + |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/add-file/upload?file_drop=true") + |> push_event("finish_file_drop", %{})} + end + @impl true def handle_info({:operation, operation}, socket) do {:noreply, handle_operation(socket, operation)} @@ -1683,6 +1717,26 @@ defmodule LivebookWeb.SessionLive do {:noreply, insert_cell_below(socket, params)} end + def handle_info({:file_entry_uploaded, file_entry}, socket) do + case socket.assigns.file_drop_metadata do + %{section_id: section_id, cell_id: cell_id} -> + {:noreply, + socket + |> assign( + insert_file_metadata: %{ + section_id: section_id, + cell_id: cell_id, + file_entry: file_entry, + handlers: handlers_for_file_entry(file_entry, socket.private.data.runtime) + } + ) + |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-file")} + + nil -> + {:noreply, socket} + end + end + def handle_info({:push_patch, to}, socket) do {:noreply, push_patch(socket, to: to)} end diff --git a/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex b/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex index a8f6d383f..2d1581d39 100644 --- a/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex +++ b/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex @@ -44,7 +44,13 @@ defmodule LivebookWeb.SessionLive.AddFileEntryUploadComponent do label="File" on_clear={JS.push("clear_file", target: @myself)} /> - <.text_field field={f[:name]} label="Name" autocomplete="off" phx-debounce="blur" /> + <.text_field + field={f[:name]} + label="Name" + id="add-file-entry-form-name" + autocomplete="off" + phx-debounce="blur" + />
+
+ + Add to files + +

- Insert file + Suggested actions

What do you want to do with the file?

-
+
- <.text_field field={f[:name]} label="Name" autocomplete="off" phx-debounce="blur" /> + <.text_field + field={f[:name]} + label="Name" + id="insert-image-form-name" + autocomplete="off" + phx-debounce="blur" + />
<.link patch={@return_to} class="button-base button-outlined-gray"> @@ -79,13 +85,21 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do def handle_event("validate", %{"data" => data} = params, socket) do upload_entries = socket.assigns.uploads.image.entries - data = + {data, socket} = case {params["_target"], data["name"], upload_entries} do {["image"], "", [entry]} -> - %{data | "name" => entry.client_name} + # Emulate input event to make sure validation errors are shown + socket = + exec_js( + socket, + JS.dispatch("input", to: "#insert-image-form-name") + |> JS.dispatch("blur", to: "#insert-image-form-name") + ) + + {%{data | "name" => entry.client_name}, socket} _ -> - data + {data, socket} end changeset = data |> changeset() |> Map.replace!(:action, :validate) diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index cfe718d3c..32e6a29ff 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -362,6 +362,47 @@ defmodule LivebookWeb.SessionLiveTest do } = Session.get_data(session.pid) end + test "inserting file after file drop upload", %{conn: conn, session: session} do + section_id = insert_section(session.pid) + cell_id = insert_text_cell(session.pid, section_id, :code) + + {:ok, runtime} = Livebook.Runtime.NoopRuntime.new() |> Livebook.Runtime.connect() + Session.set_runtime(session.pid, runtime) + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + view + |> render_hook("handle_file_drop", %{"section_id" => section_id, "cell_id" => cell_id}) + + view + |> file_input(~s{#add-file-entry-form}, :file, [ + %{ + last_modified: 1_594_171_879_000, + name: "image.jpg", + content: "content", + size: 7, + type: "text/plain" + } + ]) + |> render_upload("image.jpg") + + view + |> element(~s{#add-file-entry-form}) + |> render_submit(%{"data" => %{"name" => "image.jpg"}}) + + view + |> element(~s/#insert-file-modal [phx-click]/, "Insert as Markdown image") + |> render_click() + + assert %{ + notebook: %{ + sections: [ + %{cells: [_first_cell, %Cell.Markdown{source: "![](files/image.jpg)"}]} + ] + } + } = Session.get_data(session.pid) + end + test "deleting section with no cells requires no confirmation", %{conn: conn, session: session} do section_id = insert_section(session.pid)