Skip to content

Commit

Permalink
Allow dropping external files into the notebook (#2097)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonatanklosko authored Jul 22, 2023
1 parent 53ffb0f commit 489b609
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 48 deletions.
4 changes: 4 additions & 0 deletions assets/css/js_interop.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
133 changes: 98 additions & 35 deletions assets/js/hooks/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("dragleave", (event) => {
if (!this.el.contains(event.relatedTarget)) {
stopDragging();
}
});

this.el.addEventListener("dragover", (event) => {
const dropEl = event.target.closest(`[data-el-insert-drop-area]`);
event.stopPropagation();
event.preventDefault();
});

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;

if (dropEl) {
event.preventDefault();
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.el.addEventListener("drop", (event) => {
const dropEl = event.target.closest(`[data-el-insert-drop-area]`);

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.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");
}
},

Expand Down
2 changes: 1 addition & 1 deletion lib/livebook_web/components/form_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ defmodule LivebookWeb.FormComponents do
for={@upload.ref}
phx-drop-target={@upload.ref}
>
<span name="placeholder" class="font-medium text-gray-400">
<span class="font-medium text-gray-400">
Click to select a file or drag a local file here
</span>
</label>
Expand Down
58 changes: 56 additions & 2 deletions lib/livebook_web/live/session_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</div>
<div class="mt-6 flex space-x-3">
<button
Expand All @@ -67,13 +73,21 @@ defmodule LivebookWeb.SessionLive.AddFileEntryUploadComponent do
def handle_event("validate", %{"data" => data} = params, socket) do
upload_entries = socket.assigns.uploads.file.entries

data =
{data, socket} =
case {params["_target"], data["name"], upload_entries} do
{["file"], "", [entry]} ->
%{data | "name" => entry.client_name}
# Emulate input event to make sure validation errors are shown
socket =
exec_js(
socket,
JS.dispatch("input", to: "#add-file-entry-form-name")
|> JS.dispatch("blur", to: "#add-file-entry-form-name")
)

{%{data | "name" => entry.client_name}, socket}

_ ->
data
{data, socket}
end

changeset = data |> changeset() |> Map.replace!(:action, :validate)
Expand Down Expand Up @@ -108,6 +122,7 @@ defmodule LivebookWeb.SessionLive.AddFileEntryUploadComponent do
:ok ->
file_entry = %{name: data.name, type: :attachment}
Livebook.Session.add_file_entries(socket.assigns.session.pid, [file_entry])
send(self(), {:file_entry_uploaded, file_entry})
{:noreply, push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}")}

{:error, message} ->
Expand Down
10 changes: 10 additions & 0 deletions lib/livebook_web/live/session_live/files_list_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ defmodule LivebookWeb.SessionLive.FilesListComponent do
</h3>
<.files_info_icon />
</div>
<div
class="mt-5 h-20 rounded-lg border-2 border-dashed border-gray-400 flex items-center justify-center"
data-el-files-drop-area
id="files-dropzone"
phx-hook="Dropzone"
>
<span class="font-medium text-gray-400">
Add to files
</span>
</div>
<div class="mt-5 flex flex-col gap-1">
<div
:for={{file_entry, idx} <- Enum.with_index(@file_entries)}
Expand Down
4 changes: 2 additions & 2 deletions lib/livebook_web/live/session_live/insert_file_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ defmodule LivebookWeb.SessionLive.InsertFileComponent do
~H"""
<div class="p-6">
<h3 class="text-2xl font-semibold text-gray-800">
Insert file
Suggested actions
</h3>
<p class="mt-8 text-gray-700">
What do you want to do with the file?
</p>
<div class="mt-4 w-full flex flex-col space-y-4">
<div class="mt-8 w-full flex flex-col space-y-4">
<div
:for={{handler, idx} <- Enum.with_index(@insert_file_metadata.handlers)}
class="px-4 py-3 border border-gray-200 rounded-xl text-gray-800 pointer hover:bg-gray-50 cursor-pointer"
Expand Down
22 changes: 18 additions & 4 deletions lib/livebook_web/live/session_live/insert_image_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent 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="insert-image-form-name"
autocomplete="off"
phx-debounce="blur"
/>
</div>
<div class="mt-8 flex justify-end space-x-2">
<.link patch={@return_to} class="button-base button-outlined-gray">
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 489b609

Please sign in to comment.