feat: restore legacy drag events and Tornado launch integration

This commit is contained in:
2026-07-15 15:35:25 +09:00
parent 6e0c275fdd
commit 06a16e5741
32 changed files with 3096 additions and 101 deletions

View File

@@ -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/);