Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
spellcheck="false"
autocomplete="off"
/>
<!-- Why a dropped file was refused, in the box the file was aimed
at. The full error panel is for a job that failed; a file the
importer declined before anything started is answered where
the gesture happened, and clears itself on the next one. -->
<span class="url-drop-error hidden" id="urlDropError" role="status" aria-live="polite"></span>
<!-- File pill (shown when file selected) -->
<div class="file-pill hidden" id="filePill">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
Expand Down
67 changes: 61 additions & 6 deletions static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand All @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
183 changes: 183 additions & 0 deletions tests/e2e/file-drop.spec.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading