feat: restore legacy drag events and Tornado launch integration
This commit is contained in:
@@ -82,6 +82,7 @@ test("playlist controls send intent for selection, enabled state, movement and d
|
||||
"select-playlist-row",
|
||||
"set-playlist-enabled",
|
||||
"set-all-playlist-enabled",
|
||||
"reorder-playlist-rows",
|
||||
"move-selected-playlist-rows",
|
||||
"delete-selected-playlist-rows",
|
||||
"delete-all-playlist-rows"
|
||||
@@ -108,9 +109,314 @@ test("playlist reorder reconciles keyed DOM nodes without discarding focus", ()
|
||||
assert.doesNotMatch(app, /playlistRows\.replaceChild/);
|
||||
});
|
||||
|
||||
test("playlist native drag preserves a multi-selection and rejects checkbox drag", () => {
|
||||
class MiniClassList {
|
||||
constructor() { this.names = new Set(); }
|
||||
add(...names) { names.forEach((name) => this.names.add(name)); }
|
||||
remove(...names) { names.forEach((name) => this.names.delete(name)); }
|
||||
contains(name) { return this.names.has(name); }
|
||||
toggle(name, force) {
|
||||
if (force === undefined ? !this.names.has(name) : force) this.names.add(name);
|
||||
else this.names.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
class MiniElement extends EventTarget {
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this.parentElement = null;
|
||||
this.attributes = new Map();
|
||||
this.classList = new MiniClassList();
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.classList = new MiniClassList();
|
||||
String(value).split(/\s+/).filter(Boolean)
|
||||
.forEach((name) => this.classList.add(name));
|
||||
}
|
||||
|
||||
get className() { return [...this.classList.names].join(" "); }
|
||||
appendChild(child) {
|
||||
child.parentElement = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
contains(candidate) {
|
||||
return candidate === this || this.children.some((child) => child.contains(candidate));
|
||||
}
|
||||
closest(selector) {
|
||||
const tags = selector.split(",").map((value) => value.trim().toUpperCase());
|
||||
return tags.includes(this.tagName)
|
||||
? this
|
||||
: this.parentElement?.closest(selector) || null;
|
||||
}
|
||||
focus() {}
|
||||
addEventListener(type, listener, options) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, []);
|
||||
this.listeners.get(type).push(listener);
|
||||
super.addEventListener(type, listener, options);
|
||||
}
|
||||
invoke(type, event) {
|
||||
for (const listener of this.listeners.get(type) || []) {
|
||||
listener.call(this, event);
|
||||
}
|
||||
}
|
||||
getBoundingClientRect() { return { top: 0, height: 20 }; }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
querySelectorAll(selector) {
|
||||
const descendants = [];
|
||||
const visit = (element) => {
|
||||
element.children.forEach((child) => {
|
||||
descendants.push(child);
|
||||
visit(child);
|
||||
});
|
||||
};
|
||||
visit(this);
|
||||
if (selector === '.playlist-data-row[aria-selected="true"]') {
|
||||
return descendants.filter((element) =>
|
||||
element.classList.contains("playlist-data-row") &&
|
||||
element.getAttribute("aria-selected") === "true");
|
||||
}
|
||||
if (selector === ".dragging, .drag-before, .drag-after") {
|
||||
return descendants.filter((element) =>
|
||||
["dragging", "drag-before", "drag-after"]
|
||||
.some((name) => element.classList.contains(name)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class MiniDataTransfer {
|
||||
constructor() { this.values = new Map(); }
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
const sourceStart = app.indexOf("function clearPlaylistDragVisuals");
|
||||
const sourceEnd = app.indexOf("function renderPlaylist", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const createHarness = new Function("document", "playlistRows", "send", "playlistDragMime", `
|
||||
let draggedPlaylistRowId = null;
|
||||
let deferredPlaylistPlainSelectionRowId = null;
|
||||
let playlistDragBlockedRowId = null;
|
||||
let playlistMutationLocked = false;
|
||||
function dragDropPosition(event, element) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
||||
}
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return {
|
||||
createPlaylistElement,
|
||||
onPlaylistDragStart,
|
||||
state: () => ({ draggedPlaylistRowId, deferredPlaylistPlainSelectionRowId })
|
||||
};
|
||||
`);
|
||||
const document = { createElement: (tagName) => new MiniElement(tagName) };
|
||||
const playlistRows = new MiniElement("div");
|
||||
const sent = [];
|
||||
const harness = createHarness(
|
||||
document, playlistRows, (type, payload) => sent.push({ type, payload }),
|
||||
"text/x-mbn-playlist-row");
|
||||
const source = harness.createPlaylistElement({ rowId: "row-a" });
|
||||
source.setAttribute("aria-selected", "true");
|
||||
playlistRows.appendChild(source);
|
||||
const selectedPeer = harness.createPlaylistElement({ rowId: "row-b" });
|
||||
selectedPeer.setAttribute("aria-selected", "true");
|
||||
playlistRows.appendChild(selectedPeer);
|
||||
const target = harness.createPlaylistElement({ rowId: "row-c" });
|
||||
target.setAttribute("aria-selected", "false");
|
||||
playlistRows.appendChild(target);
|
||||
|
||||
const checkbox = source.children[0].children[0];
|
||||
const blockedTransfer = new MiniDataTransfer();
|
||||
let checkboxDragPrevented = false;
|
||||
source.invoke("pointerdown", {
|
||||
button: 0,
|
||||
currentTarget: source,
|
||||
target: checkbox,
|
||||
ctrlKey: false,
|
||||
shiftKey: false
|
||||
});
|
||||
harness.onPlaylistDragStart({
|
||||
currentTarget: source,
|
||||
// Chromium reports the draggable row as dragstart.target even though
|
||||
// pointerdown originated on the checkbox.
|
||||
target: source,
|
||||
dataTransfer: blockedTransfer,
|
||||
preventDefault: () => { checkboxDragPrevented = true; }
|
||||
});
|
||||
assert.equal(checkboxDragPrevented, true);
|
||||
assert.equal(blockedTransfer.getData("text/x-mbn-playlist-row"), "");
|
||||
|
||||
const pointerDown = new Event("pointerdown", { cancelable: true });
|
||||
Object.assign(pointerDown, { button: 0, ctrlKey: false, shiftKey: false });
|
||||
source.dispatchEvent(pointerDown);
|
||||
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, "row-a");
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const transfer = new MiniDataTransfer();
|
||||
const dragStart = new Event("dragstart", { cancelable: true });
|
||||
Object.assign(dragStart, { dataTransfer: transfer });
|
||||
source.dispatchEvent(dragStart);
|
||||
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, null);
|
||||
assert.equal(transfer.getData("text/x-mbn-playlist-row"), "row-a");
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const dragOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(dragOver, { dataTransfer: transfer, clientY: 1 });
|
||||
target.dispatchEvent(dragOver);
|
||||
assert.equal(dragOver.defaultPrevented, true);
|
||||
|
||||
const drop = new Event("drop", { cancelable: true });
|
||||
Object.assign(drop, { dataTransfer: transfer, clientY: 1 });
|
||||
target.dispatchEvent(drop);
|
||||
assert.deepEqual(sent, [{
|
||||
type: "reorder-playlist-rows",
|
||||
payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" }
|
||||
}]);
|
||||
});
|
||||
|
||||
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
|
||||
class MiniDataTransfer {
|
||||
constructor() { this.values = new Map(); }
|
||||
get types() { return [...this.values.keys()]; }
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
const sourceStart = app.indexOf("function hasDragType");
|
||||
const sourceEnd = app.indexOf(
|
||||
'playlistMoveUp.addEventListener("click"', sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const playlistDropZone = new EventTarget();
|
||||
const sent = [];
|
||||
new Function(
|
||||
"playlistDropZone",
|
||||
"send",
|
||||
"selectedCutsDragToken",
|
||||
app.slice(sourceStart, sourceEnd))(
|
||||
playlistDropZone,
|
||||
(type, payload) => sent.push({ type, payload }),
|
||||
"legacy-selected-cuts");
|
||||
|
||||
const playlistTransfer = new MiniDataTransfer();
|
||||
playlistTransfer.setData("text/x-mbn-playlist-row", "row-a");
|
||||
const playlistDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(playlistDrop, { dataTransfer: playlistTransfer });
|
||||
playlistDropZone.dispatchEvent(playlistDrop);
|
||||
assert.equal(playlistDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const externalText = new MiniDataTransfer();
|
||||
externalText.setData("text/plain", "external-text");
|
||||
const externalDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(externalDrop, { dataTransfer: externalText });
|
||||
playlistDropZone.dispatchEvent(externalDrop);
|
||||
assert.equal(externalDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const cutTransfer = new MiniDataTransfer();
|
||||
cutTransfer.setData("text/plain", "legacy-selected-cuts");
|
||||
const cutOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(cutOver, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutOver);
|
||||
assert.equal(cutOver.defaultPrevented, true);
|
||||
const cutDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(cutDrop, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutDrop);
|
||||
assert.equal(cutDrop.defaultPrevented, true);
|
||||
assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]);
|
||||
});
|
||||
|
||||
test("operator catalog drag rejects an interactive child pointer origin", () => {
|
||||
class MiniDataTransfer {
|
||||
constructor() { this.values = new Map(); }
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
const sourceStart = app.indexOf("function clearOperatorCatalogDragVisuals");
|
||||
const sourceEnd = app.indexOf("function renderOperatorCatalog", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const createHarness = new Function(
|
||||
"operatorCatalogDraftRows",
|
||||
"send",
|
||||
"operatorCatalogDragMime",
|
||||
`
|
||||
let draggedOperatorCatalogRowId = null;
|
||||
let operatorCatalogDragBlockedRowId = null;
|
||||
let operatorCatalogMutationLocked = false;
|
||||
function dragDropPosition(event, element) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
||||
}
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return {
|
||||
onOperatorCatalogPointerDown,
|
||||
onOperatorCatalogDragStart,
|
||||
state: () => ({ draggedOperatorCatalogRowId, operatorCatalogDragBlockedRowId })
|
||||
};
|
||||
`);
|
||||
const harness = createHarness(
|
||||
{ querySelectorAll: () => [] },
|
||||
() => {},
|
||||
"text/x-mbn-operator-catalog-row");
|
||||
const row = {
|
||||
dataset: { operatorCatalogRowId: "catalog-draft-row-a" },
|
||||
classList: { add() {}, remove() {} },
|
||||
closest: () => null
|
||||
};
|
||||
const input = { closest: () => input };
|
||||
harness.onOperatorCatalogPointerDown({
|
||||
button: 0,
|
||||
currentTarget: row,
|
||||
target: input
|
||||
});
|
||||
const blockedTransfer = new MiniDataTransfer();
|
||||
let prevented = false;
|
||||
harness.onOperatorCatalogDragStart({
|
||||
currentTarget: row,
|
||||
target: row,
|
||||
dataTransfer: blockedTransfer,
|
||||
preventDefault: () => { prevented = true; }
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(
|
||||
blockedTransfer.getData("text/x-mbn-operator-catalog-row"),
|
||||
"");
|
||||
assert.deepEqual(harness.state(), {
|
||||
draggedOperatorCatalogRowId: null,
|
||||
operatorCatalogDragBlockedRowId: null
|
||||
});
|
||||
|
||||
harness.onOperatorCatalogPointerDown({
|
||||
button: 0,
|
||||
currentTarget: row,
|
||||
target: row
|
||||
});
|
||||
const allowedTransfer = new MiniDataTransfer();
|
||||
harness.onOperatorCatalogDragStart({
|
||||
currentTarget: row,
|
||||
target: row,
|
||||
dataTransfer: allowedTransfer,
|
||||
preventDefault: () => { throw new Error("non-interactive drag was rejected"); }
|
||||
});
|
||||
assert.equal(
|
||||
allowedTransfer.getData("text/x-mbn-operator-catalog-row"),
|
||||
"catalog-draft-row-a");
|
||||
});
|
||||
|
||||
test("market tabs send closed identity intents and reconcile C# order", () => {
|
||||
assert.match(app, /send\("select-tab"/);
|
||||
assert.match(app, /send\("swap-tabs"/);
|
||||
assert.match(app, /send\("hover-swap-tabs"/);
|
||||
assert.match(app, /expectedSourceIndex: sourceIndex/);
|
||||
assert.match(app, /expectedTargetIndex: targetIndex/);
|
||||
assert.doesNotMatch(app, /send\("swap-tabs"/);
|
||||
assert.match(app, /dataset\.tabId/);
|
||||
assert.match(app, /const existingTabs = new Map\(\)/);
|
||||
assert.match(app, /marketTabs\.insertBefore/);
|
||||
|
||||
Reference in New Issue
Block a user