diff --git a/static/css/daw.css b/static/css/daw.css
index 0a034dda..affda047 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -183,6 +183,29 @@ input, textarea { font-family: inherit; }
}
.daw-url-zone.has-file #url { display: none; }
+/* Why a dropped file was refused, said in the box it was aimed at.
+ The full error panel is for a job that failed: it takes a region and carries
+ a retry button, and a file declined before anything started has nothing to
+ retry. Sits where the input text does, and hides it, so the box says one
+ thing at a time (#584). */
+.url-drop-error {
+ flex: 1;
+ min-width: 0;
+ color: var(--danger);
+ font-size: 13px;
+ line-height: 1.25;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+/* A plain class, toggled from JS alongside the message.
+ `:has()` reads better here, and a sibling combinator cannot work at all
+ because the message follows the input in the markup. But this ships in three
+ webviews, and the oldest of them is the one that matters: WebKitGTK only
+ grew :has() in 2.38, and Linux is where this bug was reported from. A class
+ costs one line in the handler and works in every engine. */
+.daw-url-zone.has-drop-error #url { display: none; }
+
/* Composer vertical divider */
.daw-composer-sep {
width: 1px;
diff --git a/static/index.html b/static/index.html
index 805db14a..d7862198 100644
--- a/static/index.html
+++ b/static/index.html
@@ -50,6 +50,11 @@
spellcheck="false"
autocomplete="off"
/>
+
+
diff --git a/static/js/main.js b/static/js/main.js
index 91d70a16..f4987726 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -540,8 +540,35 @@ function wireFileDrop() {
const fileName = document.getElementById("fileName");
const fileSize = document.getElementById("fileSize");
const fileClear = document.getElementById("fileClear");
+ const dropError = document.getElementById("urlDropError");
if (!urlWrap || !urlInput || !fileInput || !filePill) return;
+ // Why a dropped file was refused, said in the box it was aimed at.
+ //
+ // showError() is the panel for a job that failed: it takes over a region and
+ // carries a retry button. A file the importer declined before anything
+ // started has nothing to retry and does not deserve that much room, and the
+ // reason belongs next to the gesture rather than somewhere else on screen.
+ //
+ // Cleared on the next drop, on typing, and on a timer, so it can never be
+ // mistaken for the state of a file that is currently armed.
+ let dropErrorTimer = null;
+ function showDropError(message) {
+ if (!dropError) return;
+ dropError.textContent = message;
+ dropError.classList.remove("hidden");
+ urlWrap.classList.add("has-drop-error");
+ clearTimeout(dropErrorTimer);
+ dropErrorTimer = setTimeout(clearDropError, 6000);
+ }
+ function clearDropError() {
+ clearTimeout(dropErrorTimer);
+ dropError?.classList.add("hidden");
+ urlWrap.classList.remove("has-drop-error");
+ if (dropError) dropError.textContent = "";
+ }
+ urlInput.addEventListener("input", clearDropError);
+
function formatBytes(n) {
return n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
}
@@ -560,13 +587,13 @@ function wireFileDrop() {
// stray file. Only complain if nothing usable came through.
const audio = all.filter(isAudioFile);
if (!audio.length) {
- showError(t("upload.unsupportedFormat"));
+ showDropError(t("upload.unsupportedFormat"));
return;
}
const files = audio.filter((f) => f.size <= MAX_UPLOAD_BYTES);
const oversized = audio.length - files.length;
if (!files.length) {
- showError(t("upload.fileTooLarge", { size: formatBytes(audio[0].size), max: formatBytes(MAX_UPLOAD_BYTES) }));
+ showDropError(t("upload.fileTooLarge", { size: formatBytes(audio[0].size), max: formatBytes(MAX_UPLOAD_BYTES) }));
return;
}
@@ -579,6 +606,7 @@ function wireFileDrop() {
const bytes = files.reduce((sum, f) => sum + f.size, 0);
fileSize.textContent = formatBytes(bytes);
}
+ clearDropError();
filePill.classList.remove("hidden");
urlWrap.classList.add("has-file");
// Cache the File objects directly on the element so job.js can always
@@ -617,16 +645,43 @@ function wireFileDrop() {
// happily import the same file twice.
fileInput._clear = clearFile;
+ const draggingFiles = (e) => !!e.dataTransfer?.types?.includes("Files");
+
+ // The URL zone keeps its own hover affordance. It is no longer the only place
+ // a file can land, so this is now about showing where it will go rather than
+ // about being the only target that works.
urlWrap.addEventListener("dragover", (e) => {
- if (!e.dataTransfer.types.includes("Files")) return;
- e.preventDefault();
- e.dataTransfer.dropEffect = "copy";
+ if (!draggingFiles(e)) return;
urlWrap.classList.add("drag-over");
});
urlWrap.addEventListener("dragleave", (e) => {
if (!urlWrap.contains(e.relatedTarget)) urlWrap.classList.remove("drag-over");
});
- urlWrap.addEventListener("drop", (e) => {
+
+ // A file dropped anywhere in the window is treated as a drop on the URL zone.
+ //
+ // Not a convenience. Everywhere else was previously left to the browser,
+ // which navigates to the file: in a tab that is merely surprising, but the
+ // desktop shell has no address bar and no back button, so the window is
+ // replaced by the webview's bare media player and the only way out is to
+ // quit the app (#584). Preventing the default is what fixes that; routing it
+ // to applyFiles is what makes the gesture do the obvious thing instead of
+ // nothing.
+ //
+ // Both handlers are guarded on "Files" so the library's own drags -- tracks
+ // between folders, into the lanes, onto the trash -- are untouched. Those
+ // carry no file list, and their handlers bail before preventDefault for the
+ // same reason, so the two never see each other's gestures.
+ //
+ // dragover must preventDefault too: without it the browser refuses the drop
+ // and no drop event is ever delivered to cancel.
+ document.addEventListener("dragover", (e) => {
+ if (!draggingFiles(e)) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ });
+ document.addEventListener("drop", (e) => {
+ if (!draggingFiles(e)) return;
e.preventDefault();
urlWrap.classList.remove("drag-over");
applyFiles(e.dataTransfer.files);
diff --git a/tests/e2e/file-drop.spec.mjs b/tests/e2e/file-drop.spec.mjs
new file mode 100644
index 00000000..fed2f314
--- /dev/null
+++ b/tests/e2e/file-drop.spec.mjs
@@ -0,0 +1,183 @@
+// A dropped audio file must be treated as an import, wherever it lands.
+//
+// Everywhere outside the URL zone used to be left to the browser, which
+// navigates to the file. In a tab that is surprising; in the desktop shell
+// there is no address bar and no back button, so the window is replaced by the
+// webview's bare media player and the only way out is to quit the app (#584).
+import { test, expect } from "@playwright/test";
+import { openStudio, seedLibrary, stubTauri, stubUpdateCheck } from "./helpers.mjs";
+
+// A real WAV, small enough to be uninteresting. The extension is what the
+// filter actually reads.
+const WAV = Buffer.concat([
+ Buffer.from("RIFF"), Buffer.from([36, 0, 0, 0]), Buffer.from("WAVEfmt "),
+ Buffer.from([16, 0, 0, 0, 1, 0, 1, 0, 68, 172, 0, 0, 136, 88, 1, 0, 2, 0, 16, 0]),
+ Buffer.from("data"), Buffer.from([0, 0, 0, 0]),
+]);
+
+async function dropOn(page, selector, name = "dropped.wav", bytes = WAV) {
+ const handle = await page.evaluateHandle(
+ ([n, data]) => {
+ const dt = new DataTransfer();
+ dt.items.add(new File([new Uint8Array(data)], n, { type: "audio/wav" }));
+ return dt;
+ },
+ [name, [...bytes]],
+ );
+ await page.dispatchEvent(selector, "dragover", { dataTransfer: handle });
+ await page.dispatchEvent(selector, "drop", { dataTransfer: handle });
+}
+
+// Whether the drop's default action was cancelled, recorded at the end of the
+// bubble chain. This, not page.url(), is what has to be asserted: a synthetic
+// drop never triggers the browser's real navigation, so a URL check passes with
+// the fix ripped out and proves nothing. Cancelling the default is the thing
+// that stops the webview replacing the app with its media player.
+async function watchDropDefault(page) {
+ await page.evaluate(() => {
+ window.__dropDefaultPrevented = null;
+ window.addEventListener(
+ "drop",
+ (e) => { window.__dropDefaultPrevented = e.defaultPrevented; },
+ { capture: false },
+ );
+ });
+}
+
+const dropWasCancelled = (page) => page.evaluate(() => window.__dropDefaultPrevented);
+
+const pill = (page) =>
+ page.evaluate(() => {
+ const p = document.getElementById("filePill");
+ return {
+ visible: p ? !p.classList.contains("hidden") : null,
+ name: document.getElementById("fileName")?.textContent || "",
+ armed: (document.getElementById("fileInput")?._files || []).length,
+ };
+ });
+
+test.describe("file drop", () => {
+ test("a file dropped on the page body arms the import", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+
+ expect((await pill(page)).visible).toBe(false);
+
+ // Deliberately not the URL zone. This is the gesture that used to navigate.
+ await dropOn(page, "body");
+
+ const p = await pill(page);
+ expect(p.visible, "the import pill is armed").toBe(true);
+ expect(p.name).toBe("dropped.wav");
+ expect(p.armed).toBe(1);
+ });
+
+ test("the drop's default is cancelled, so the webview cannot navigate", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+ await watchDropDefault(page);
+
+ await dropOn(page, "body");
+ await page.waitForTimeout(200);
+
+ expect(await dropWasCancelled(page), "an uncancelled drop is the bug").toBe(true);
+ await expect(page.locator(".url-wrap")).toBeVisible();
+ });
+
+ test("dropping still works with a track open", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+ await dropOn(page, ".daw-content", "second.wav");
+
+ const p = await pill(page);
+ expect(p.visible).toBe(true);
+ expect(p.name).toBe("second.wav");
+ });
+
+ test("a non-audio file is refused rather than navigated to", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+ await watchDropDefault(page);
+
+ await dropOn(page, "body", "notes.txt", Buffer.from("hello"));
+ await page.waitForTimeout(200);
+
+ // Refusing it is not enough: an unsupported file the app declines must
+ // still not be handed back to the browser to open.
+ expect(await dropWasCancelled(page), "a refused file must be cancelled too").toBe(true);
+ expect((await pill(page)).visible, "nothing armed").toBe(false);
+
+ // And the refusal has to be visible, in the box the file was aimed at.
+ const err = page.locator("#urlDropError");
+ await expect(err).toBeVisible();
+ await expect(err).toContainText(/supported/i);
+
+ const shown = await err.evaluate((el) => {
+ const rgb = getComputedStyle(el).color.match(/\d+/g).map(Number);
+ return { rgb, inputHidden: getComputedStyle(document.getElementById("url")).display === "none" };
+ });
+ // Reddish: red dominant over both other channels rather than a hard-coded
+ // hex, so retheming the danger colour does not fail this.
+ expect(shown.rgb[0], `red channel in rgb(${shown.rgb})`).toBeGreaterThan(shown.rgb[1] + 40);
+ expect(shown.rgb[0]).toBeGreaterThan(shown.rgb[2] + 40);
+ expect(shown.inputHidden, "the box says one thing at a time").toBe(true);
+ });
+
+ test("a refusal clears itself once something valid arrives", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+
+ await dropOn(page, "body", "notes.txt", Buffer.from("hello"));
+ await expect(page.locator("#urlDropError")).toBeVisible();
+
+ // A stale refusal sitting next to an armed file would describe the wrong
+ // thing entirely.
+ await dropOn(page, "body", "good.wav");
+ await expect(page.locator("#urlDropError")).toBeHidden();
+ expect((await pill(page)).name).toBe("good.wav");
+ });
+
+ test("typing dismisses a refusal", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+
+ await dropOn(page, "body", "notes.txt", Buffer.from("hello"));
+ await expect(page.locator("#urlDropError")).toBeVisible();
+
+ // The input is hidden while the message shows, so the message must not be
+ // able to lock the user out of the box it is sitting in.
+ await page.locator("#urlDropError").click();
+ await page.evaluate(() => {
+ const i = document.getElementById("url");
+ i.value = "a";
+ i.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await expect(page.locator("#urlDropError")).toBeHidden();
+ await expect(page.locator("#url")).toBeVisible();
+ });
+
+ test("a library drag is not mistaken for a file drop", async ({ page }) => {
+ // The library's own drags carry no file list. If the document handler
+ // stopped guarding on that, dragging a track would arm the importer.
+ await openStudio(page, { tauri: true });
+ const handle = await page.evaluateHandle(() => new DataTransfer());
+ await page.dispatchEvent("body", "dragover", { dataTransfer: handle });
+ await page.dispatchEvent("body", "drop", { dataTransfer: handle });
+
+ expect((await pill(page)).visible).toBe(false);
+ });
+});