mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Drop files only into the textarea under the cursor
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
"minWidth": 960,
|
||||
"minHeight": 600,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
"hiddenTitle": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -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", () => {
|
||||
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()),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (dragEnterUnlistenFn) dragEnterUnlistenFn();
|
||||
if (dragLeaveUnlistenFn) dragLeaveUnlistenFn();
|
||||
if (dragDropUnlistenFn) dragDropUnlistenFn();
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user