Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow dropping external files into the notebook #2097

Merged
merged 4 commits into from
Jul 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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