mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-25 12:56:13 +08:00
Allow dropping external files into the notebook (#2097)
This commit is contained in:
parent
53ffb0f1f5
commit
489b609154
9 changed files with 250 additions and 49 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: ""}]}
|
||||
]
|
||||
}
|
||||
} = 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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue