Drop files only into the textarea under the cursor

This commit is contained in:
Rūdolfs Ošiņš
2026-10-06 14:44:28 +02:00
parent d6145300c1
commit 118abffdea
6 changed files with 255 additions and 42 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"minWidth": 960,
"minHeight": 600,
"titleBarStyle": "Overlay",
"hiddenTitle": true
"hiddenTitle": true,
"dragDropEnabled": false
}
],
"security": {
+61 -41
View File
@@ -1,13 +1,10 @@
<script lang="ts">
import type { MentionInsertion } from "@app/components/MentionAutocomplete.svelte";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { UnlistenFn } from "@tauri-apps/api/event";
import type { ComponentProps, Snippet } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import debounce from "lodash/debounce";
import { onDestroy, onMount } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { basename } from "@app/lib/embeds";
@@ -123,9 +120,10 @@
$state(undefined);
let draggingOver = $state(false);
let embedUploadError: string | undefined = $state();
let dragEnterUnlistenFn: UnlistenFn | undefined = undefined;
let dragLeaveUnlistenFn: UnlistenFn | undefined = undefined;
let dragDropUnlistenFn: UnlistenFn | undefined = undefined;
let dragDepth = 0;
// Mirrors `MAX_EMBED_SIZE` in `crates/radicle-types/src/traits/thread.rs`.
const MAX_EMBED_SIZE = 10_485_760;
const restoreDragDropText = debounce(() => {
embedUploadError = undefined;
@@ -178,33 +176,60 @@
textareaElement?.setSelectionRange(selectionStart, selectionEnd);
}
onMount(async () => {
if (window.__TAURI_INTERNALS__) {
if (attachEnabled) {
dragEnterUnlistenFn = await listen("tauri://drag-enter", () => {
draggingOver = true;
});
dragLeaveUnlistenFn = await listen("tauri://drag-leave", () => {
draggingOver = false;
});
dragDropUnlistenFn = await listen<{
paths: string[];
position: { x: number; y: number };
}>("tauri://drag-drop", async event => {
draggingOver = false;
await uploadEmbeds(event.payload.paths, basename, saveByPath);
});
}
async function saveFileBytes(file: File) {
if (file.size > MAX_EMBED_SIZE) {
throw new Error(`${file.name} exceeds the embed size limit`);
}
});
return invoke<string>("save_embed_by_bytes", {
rid,
name: file.name,
bytes: new Uint8Array(await file.arrayBuffer()),
});
}
onDestroy(() => {
if (dragEnterUnlistenFn) dragEnterUnlistenFn();
if (dragLeaveUnlistenFn) dragLeaveUnlistenFn();
if (dragDropUnlistenFn) dragDropUnlistenFn();
});
function hasFiles(event: DragEvent): boolean {
return event.dataTransfer?.types.includes("Files") ?? false;
}
function acceptsDrop(event: DragEvent): boolean {
return attachEnabled && !preview && hasFiles(event);
}
function handleDragEnter(event: DragEvent) {
if (!hasFiles(event)) return;
dragDepth += 1;
if (acceptsDrop(event)) {
event.preventDefault();
draggingOver = true;
}
}
function handleDragOver(event: DragEvent) {
if (!acceptsDrop(event) || !event.dataTransfer) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
function handleDragLeave(event: DragEvent) {
if (!hasFiles(event)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
draggingOver = false;
}
}
function handleDrop(event: DragEvent) {
if (!hasFiles(event)) return;
dragDepth = 0;
draggingOver = false;
if (!acceptsDrop(event) || !event.dataTransfer) return;
event.preventDefault();
void uploadEmbeds(
Array.from(event.dataTransfer.files),
file => file.name,
saveFileBytes,
);
}
async function attachEmbedsByPaths(paths: string[]) {
await uploadEmbeds(paths, basename, saveByPath);
@@ -228,16 +253,7 @@
invoke<string>("save_embed_by_clipboard", { name: file.name, rid }),
);
} else {
await uploadEmbeds(
files,
file => file.name,
async file =>
invoke<string>("save_embed_by_bytes", {
rid,
name: file.name,
bytes: new Uint8Array(await file.arrayBuffer()),
}),
);
await uploadEmbeds(files, file => file.name, saveFileBytes);
}
}
}
@@ -410,6 +426,10 @@
<div
class="comment-section"
aria-label="extended-textarea"
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
class:inline
onkeydown={event => {
if (!preview) return;
+25
View File
@@ -0,0 +1,25 @@
export function disableDropNavigation(): void {
const isNativeTextDrop = (event: DragEvent) =>
!(event.dataTransfer?.types.includes("Files") ?? false) &&
event.target instanceof Element &&
event.target.closest(
'input, textarea, [contenteditable]:not([contenteditable="false"])',
) !== null;
window.addEventListener("dragover", event => {
if (
event.defaultPrevented ||
isNativeTextDrop(event) ||
!event.dataTransfer
) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "none";
});
window.addEventListener("drop", event => {
if (!isNativeTextDrop(event)) {
event.preventDefault();
}
});
}
+2
View File
@@ -2,10 +2,12 @@ import "overlayscrollbars/overlayscrollbars.css";
import { mount } from "svelte";
import App from "./App.svelte";
import { disableDropNavigation } from "./lib/disableDropNavigation";
import { disableNativeContextMenu } from "./lib/disableNativeContextMenu";
const app = mount(App, { target: document.body });
disableNativeContextMenu();
disableDropNavigation();
export default app;
+88
View File
@@ -0,0 +1,88 @@
import type { JSHandle, Locator, Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager.js";
import { createProject } from "@tests/support/collaboration.js";
import { expect, test, waitForCommand } from "@tests/support/fixtures.js";
async function openIssue(page: Page, peer: RadiclePeer) {
const { rid, issueId } = await createProject(peer);
await page.goto(`/repos/${rid}/issues/${issueId}`);
await expect(page.getByText("Nobody reads them.")).toBeVisible();
}
function fileTransfer(
page: Page,
name: string,
): Promise<JSHandle<DataTransfer>> {
return page.evaluateHandle(name => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(["loud"], name, { type: "text/plain" }));
return dataTransfer;
}, name);
}
async function dropFile(target: Locator, dataTransfer: JSHandle<DataTransfer>) {
for (const type of ["dragenter", "dragover", "drop"]) {
await target.dispatchEvent(type, { dataTransfer });
}
}
test("a dropped file only goes into the textarea under it", async ({
page,
peer,
}) => {
await openIssue(page, peer);
await page.getByText("Nobody reads them.").hover();
await page.getByTitle("Edit description").click();
const description = page.getByPlaceholder("Leave your comment");
await expect(description).toHaveValue("Nobody reads them.");
const composer = page.getByPlaceholder("Leave a comment");
await waitForCommand(page, "save_embed_by_bytes", () =>
fileTransfer(page, "notes.txt").then(transfer =>
dropFile(composer, transfer),
),
);
await expect(composer).toHaveValue(/\[notes\.txt\]\(\w+\)/);
await expect(description).toHaveValue("Nobody reads them.");
});
test("a file dropped while previewing is refused", async ({ page, peer }) => {
await openIssue(page, peer);
let uploads = 0;
page.on("request", request => {
if (new URL(request.url()).pathname === "/save_embed_by_bytes") {
uploads++;
}
});
await page.getByPlaceholder("Leave a comment").fill("Draft");
const composer = page.getByLabel("extended-textarea");
await composer.getByRole("button", { name: "Preview" }).click();
await dropFile(composer, await fileTransfer(page, "notes.txt"));
await composer.getByRole("button", { name: "Edit" }).click();
await expect(page.getByPlaceholder("Leave a comment")).toHaveValue("Draft");
expect(uploads).toBe(0);
});
test("the drop highlight clears once the file leaves", async ({
page,
peer,
}) => {
await openIssue(page, peer);
const transfer = await fileTransfer(page, "notes.txt");
const composer = page.getByPlaceholder("Leave a comment");
const highlight = page.getByText("Drop files to add them as embeds.");
await composer.dispatchEvent("dragenter", { dataTransfer: transfer });
await expect(highlight).toBeVisible();
// The highlight covers the textarea, so it is entered before that is left.
await highlight.dispatchEvent("dragenter", { dataTransfer: transfer });
await composer.dispatchEvent("dragleave", { dataTransfer: transfer });
await expect(highlight).toBeVisible();
await highlight.dispatchEvent("dragleave", { dataTransfer: transfer });
await expect(highlight).toBeHidden();
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, test } from "vitest";
import { disableDropNavigation } from "@app/lib/disableDropNavigation";
// happy-dom's DragEvent ignores `dataTransfer`, so it is attached by hand.
function drag(type: "dragover" | "drop", target: Element, types: string[]) {
const dataTransfer = { types, dropEffect: "copy" };
const event = new DragEvent(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
target.dispatchEvent(event);
return {
prevented: event.defaultPrevented,
dropEffect: dataTransfer.dropEffect,
};
}
function element(html: string): Element {
const template = document.createElement("template");
template.innerHTML = html;
const node = template.content.firstElementChild as Element;
document.body.append(node);
return node;
}
beforeAll(() => {
disableDropNavigation();
});
describe("disableDropNavigation", () => {
test.each([
["a link", ["text/uri-list", "text/plain"]],
["a file", ["Files"]],
])("refuses %s dropped on the page", (_, types) => {
const page = element("<div>page</div>");
expect(drag("dragover", page, types)).toEqual({
prevented: true,
dropEffect: "none",
});
expect(drag("drop", page, types).prevented).toBe(true);
});
test.each([
["a textarea", "<textarea></textarea>"],
["an input", "<input>"],
["an editable element", '<div contenteditable="true">x</div>'],
])("leaves text dropped into %s to the webview", (_, html) => {
const field = element(html);
const types = ["text/plain"];
expect(drag("dragover", field, types)).toEqual({
prevented: false,
dropEffect: "copy",
});
expect(drag("drop", field, types).prevented).toBe(false);
});
test("refuses text dropped on an element that isn't editable", () => {
const field = element('<div contenteditable="false">x</div>');
expect(drag("drop", field, ["text/plain"]).prevented).toBe(true);
});
test("refuses a file over a field that didn't accept it", () => {
const field = element("<textarea></textarea>");
expect(drag("dragover", field, ["Files"])).toEqual({
prevented: true,
dropEffect: "none",
});
});
test("keeps the drop effect of a field that accepted the file", () => {
const field = element("<div></div>");
field.addEventListener("dragover", event => event.preventDefault());
expect(drag("dragover", field, ["Files"])).toEqual({
prevented: true,
dropEffect: "copy",
});
});
});