Allow dropping external files into the notebook (#2097)

This commit is contained in:
Jonatan Kłosko 2023-07-22 11:13:06 +02:00 committed by GitHub
parent 53ffb0f1f5
commit 489b609154
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 250 additions and 49 deletions

View file

@ -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;
}

View file

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

View file

@ -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>

View file

@ -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

View file

@ -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
@ -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)
@ -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} ->

View file

@ -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)}

View file

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

View file

@ -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">
@ -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)

View file

@ -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)