Files
2026-07-12 21:26:08 +08:00

769 lines
32 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
GRAPH_GESTURE_BLOCKER_TARGET_KINDS,
GRAPH_GESTURE_SELECTORS,
GRAPH_OWNED_TARGET_KINDS,
GraphGestureController,
GraphGestureStateMachine,
classifyGraphEventTarget,
classifyGraphPointerDownTarget,
classifyGraphWheelTarget,
graphGestureTargetOwnership,
isGraphGestureBlockerTarget,
isGraphOwnedGestureTarget,
type GraphGestureIntent,
type GraphGestureTarget,
type GraphGestureTargetLike
} from "../src/render";
class FakeTarget implements GraphGestureTargetLike {
readonly dataset: Record<string, string | undefined>;
readonly tagName?: string;
readonly type?: string;
readonly isContentEditable?: boolean;
private readonly matchesBySelector = new Map<string, FakeTarget | null>();
constructor(options: {
dataset?: Record<string, string | undefined>;
tagName?: string;
type?: string;
isContentEditable?: boolean;
closest?: Record<string, FakeTarget | null>;
} = {}) {
this.dataset = options.dataset || {};
this.tagName = options.tagName;
this.type = options.type;
this.isContentEditable = options.isContentEditable;
for (const [selector, target] of Object.entries(options.closest || {})) {
this.matchesBySelector.set(selector, target);
}
}
closest(selector: string): GraphGestureTargetLike | null {
if (matchesSelf(this, selector)) return this;
return this.matchesBySelector.get(selector) || null;
}
}
describe("graph gesture target classifier", () => {
it("declares graph-owned targets and gesture blockers as a stable contract", () => {
assert.deepEqual([...GRAPH_OWNED_TARGET_KINDS], ["graph-blank", "node", "community-wash", "aggregation-container", "edge"]);
assert.deepEqual([...GRAPH_GESTURE_BLOCKER_TARGET_KINDS], [
"minimap",
"toolbar",
"search",
"legend",
"drawer",
"text-control",
"unknown"
]);
assert.equal(GRAPH_GESTURE_SELECTORS.node, ".node");
assert.equal(GRAPH_GESTURE_SELECTORS.communityWash, ".community-wash");
assert.equal(GRAPH_GESTURE_SELECTORS.drawer, ".graph-reader, .graph-selection-panel, [data-graph-drawer=\"true\"]");
});
it("classifies graph-owned targets separately from gesture blockers", () => {
const graphOwnedTargets: GraphGestureTarget[] = [
{ kind: "graph-blank" },
{ kind: "node", id: "node-a" },
{ kind: "community-wash", id: "community-a" },
{ kind: "edge", id: "edge-a" }
];
const blockerTargets: GraphGestureTarget[] = [
{ kind: "minimap" },
{ kind: "toolbar" },
{ kind: "search" },
{ kind: "legend" },
{ kind: "drawer" },
{ kind: "text-control" },
{ kind: "unknown" }
];
for (const target of graphOwnedTargets) {
assert.equal(isGraphOwnedGestureTarget(target), true);
assert.equal(isGraphGestureBlockerTarget(target), false);
assert.equal(graphGestureTargetOwnership(target), "graph-owned");
}
for (const target of blockerTargets) {
assert.equal(isGraphOwnedGestureTarget(target), false);
assert.equal(isGraphGestureBlockerTarget(target), true);
assert.equal(graphGestureTargetOwnership(target), "graph-blocker");
}
});
it("classifies graph target kinds and ids without a DOM dependency", () => {
assert.deepEqual(classifyGraphEventTarget(blankTarget()), { kind: "graph-blank" });
assert.deepEqual(classifyGraphEventTarget(graphBlankTarget()), { kind: "graph-blank" });
assert.deepEqual(classifyGraphEventTarget(nodeTarget("node-a")), { kind: "node", id: "node-a" });
assert.deepEqual(classifyGraphEventTarget(communityWashTarget("community-a")), { kind: "community-wash", id: "community-a" });
assert.deepEqual(classifyGraphEventTarget(edgeTarget("edge-a")), { kind: "edge", id: "edge-a" });
assert.deepEqual(classifyGraphEventTarget(controlTarget(".mini-map")), { kind: "minimap" });
assert.deepEqual(classifyGraphEventTarget(controlTarget(".graph-toolbar")), { kind: "toolbar" });
assert.deepEqual(classifyGraphEventTarget(controlTarget(".graph-search")), { kind: "search" });
assert.deepEqual(classifyGraphEventTarget(controlTarget(".community-legend")), { kind: "legend" });
assert.deepEqual(classifyGraphEventTarget(controlTarget(".graph-reader, .graph-selection-panel, [data-graph-drawer=\"true\"]")), { kind: "drawer" });
assert.deepEqual(classifyGraphEventTarget(new FakeTarget({ tagName: "input", type: "search" })), { kind: "text-control" });
});
it("lets wheel zoom over blank, node, community wash, and edge targets", () => {
assert.equal(classifyGraphWheelTarget(blankTarget()).intent, "zoom");
assert.equal(classifyGraphWheelTarget(nodeTarget("node-a")).intent, "zoom");
assert.equal(classifyGraphWheelTarget(communityWashTarget("community-a")).intent, "zoom");
assert.equal(classifyGraphWheelTarget(edgeTarget("edge-a")).intent, "zoom");
});
it("routes browser zoom shortcut wheels to graph zoom on graph-owned targets", () => {
assert.deepEqual(classifyGraphWheelTarget(nodeTarget("node-a"), { metaKey: true }), {
intent: "zoom",
target: { kind: "node", id: "node-a" }
});
assert.deepEqual(classifyGraphWheelTarget(blankTarget(), { ctrlKey: true }), {
intent: "zoom",
target: { kind: "graph-blank" }
});
assert.deepEqual(classifyGraphWheelTarget(communityWashTarget("community-a"), { ctrlKey: true }), {
intent: "zoom",
target: { kind: "community-wash", id: "community-a" }
});
assert.deepEqual(classifyGraphWheelTarget(edgeTarget("edge-a"), { metaKey: true }), {
intent: "zoom",
target: { kind: "edge", id: "edge-a" }
});
});
it("blocks wheel zoom over controls, drawers, minimap, and text editing targets", () => {
for (const target of [
controlTarget(".graph-search"),
controlTarget(".graph-toolbar"),
controlTarget(".community-legend"),
controlTarget(".graph-reader, .graph-selection-panel, [data-graph-drawer=\"true\"]"),
controlTarget(".mini-map"),
new FakeTarget({ tagName: "textarea" }),
new FakeTarget({ tagName: "input", type: "text" }),
new FakeTarget({ isContentEditable: true })
]) {
assert.equal(classifyGraphWheelTarget(target).intent, "blocked");
assert.equal(classifyGraphWheelTarget(target, { ctrlKey: true }).intent, "blocked");
assert.equal(classifyGraphWheelTarget(target, { metaKey: true }).intent, "blocked");
}
});
it("classifies pointerdown candidates for node drag, community click, and graph pan", () => {
assert.deepEqual(classifyGraphPointerDownTarget(nodeTarget("node-a")), {
intent: "node-drag-candidate",
target: { kind: "node", id: "node-a" }
});
assert.deepEqual(classifyGraphPointerDownTarget(communityWashTarget("community-a")), {
intent: "community-click-candidate",
target: { kind: "community-wash", id: "community-a" }
});
assert.deepEqual(classifyGraphPointerDownTarget(blankTarget()), {
intent: "blank-pan-candidate",
target: { kind: "graph-blank" }
});
assert.deepEqual(classifyGraphPointerDownTarget(edgeTarget("edge-a")), {
intent: "blank-pan-candidate",
target: { kind: "edge", id: "edge-a" }
});
});
it("blocks pointerdown over non-gesture controls", () => {
assert.deepEqual(classifyGraphPointerDownTarget(controlTarget(".mini-map")), {
intent: "blocked",
target: { kind: "minimap" }
});
assert.deepEqual(classifyGraphPointerDownTarget(controlTarget(".graph-toolbar")), {
intent: "blocked",
target: { kind: "toolbar" }
});
assert.deepEqual(classifyGraphPointerDownTarget(new FakeTarget({ tagName: "input", type: "text" })), {
intent: "blocked",
target: { kind: "text-control" }
});
});
});
describe("graph gesture state machine", () => {
it("turns a low-movement node pointer sequence into a node click intent", () => {
const machine = new GraphGestureStateMachine({ dragThreshold: 4 });
machine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(7, 100, 100));
assert.deepEqual(machine.pointerMove(pointer(7, 102, 102)), []);
assert.deepEqual(machine.pointerUp(pointer(7, 102, 102)), [
{ kind: "node-click", nodeId: "node-a", additive: false, pointerId: 7 }
]);
assert.equal(machine.snapshot(), null);
});
it("turns a high-movement node pointer sequence into drag start, move, and end intents without a click", () => {
const machine = new GraphGestureStateMachine({ dragThreshold: 4 });
machine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(8, 10, 10));
assert.deepEqual(machine.pointerMove(pointer(8, 20, 18)), [
{ kind: "node-drag-start", nodeId: "node-a", pointerId: 8, screenPoint: { x: 20, y: 18 } },
{ kind: "node-drag-move", nodeId: "node-a", pointerId: 8, screenPoint: { x: 20, y: 18 }, delta: { x: 10, y: 8 } }
]);
assert.deepEqual(machine.pointerMove(pointer(8, 25, 28)), [
{ kind: "node-drag-move", nodeId: "node-a", pointerId: 8, screenPoint: { x: 25, y: 28 }, delta: { x: 5, y: 10 } }
]);
assert.deepEqual(machine.pointerUp(pointer(8, 25, 28)), [
{ kind: "node-drag-end", nodeId: "node-a", pointerId: 8, screenPoint: { x: 25, y: 28 } }
]);
assert.equal(machine.snapshot(), null);
});
it("preserves shift additive state for node clicks", () => {
const machine = new GraphGestureStateMachine({ dragThreshold: 4 });
machine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(9, 40, 40, { shiftKey: true }));
assert.deepEqual(machine.pointerUp(pointer(9, 40, 40)), [
{ kind: "node-click", nodeId: "node-a", additive: true, pointerId: 9 }
]);
});
it("turns a low-movement community wash sequence into a community click", () => {
const machine = new GraphGestureStateMachine({ dragThreshold: 4 });
machine.pointerDown(classifyGraphPointerDownTarget(communityWashTarget("community-a")), pointer(10, 120, 80));
assert.deepEqual(machine.pointerMove(pointer(10, 121, 82)), []);
assert.deepEqual(machine.pointerUp(pointer(10, 121, 82)), [
{ kind: "community-click", communityId: "community-a", pointerId: 10 }
]);
});
it("cancels a community click after movement above threshold", () => {
const machine = new GraphGestureStateMachine({ dragThreshold: 4 });
machine.pointerDown(classifyGraphPointerDownTarget(communityWashTarget("community-a")), pointer(11, 0, 0));
assert.deepEqual(machine.pointerMove(pointer(11, 10, 0)), [
{ kind: "community-click-cancelled", communityId: "community-a", pointerId: 11, reason: "moved" }
]);
assert.deepEqual(machine.pointerUp(pointer(11, 10, 0)), []);
});
it("turns blank canvas movement into pan intents and low movement into blank click", () => {
const machine = new GraphGestureStateMachine({ dragThreshold: 4 });
machine.pointerDown(classifyGraphPointerDownTarget(blankTarget()), pointer(12, 50, 50));
assert.deepEqual(machine.pointerUp(pointer(12, 51, 51)), [{ kind: "blank-click", pointerId: 12 }]);
machine.pointerDown(classifyGraphPointerDownTarget(blankTarget()), pointer(13, 50, 50));
assert.deepEqual(machine.pointerMove(pointer(13, 58, 52)), [
{ kind: "blank-pan-start", pointerId: 13, screenPoint: { x: 58, y: 52 } },
{ kind: "blank-pan-move", pointerId: 13, screenPoint: { x: 58, y: 52 }, delta: { x: 8, y: 2 } }
]);
assert.deepEqual(machine.pointerMove(pointer(13, 60, 57)), [
{ kind: "blank-pan-move", pointerId: 13, screenPoint: { x: 60, y: 57 }, delta: { x: 2, y: 5 } }
]);
assert.deepEqual(machine.pointerUp(pointer(13, 60, 57)), [
{ kind: "blank-pan-end", pointerId: 13, screenPoint: { x: 60, y: 57 } }
]);
});
it("cleans up active gestures on pointercancel and lostpointercapture without false clicks or pins", () => {
const nodeMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
nodeMachine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(14, 0, 0));
nodeMachine.pointerMove(pointer(14, 10, 0));
assert.deepEqual(nodeMachine.pointerCancel({ pointerId: 14 }), [
{ kind: "node-drag-cancel", nodeId: "node-a", pointerId: 14, reason: "pointercancel" }
]);
assert.equal(nodeMachine.snapshot(), null);
const lostNodeMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
lostNodeMachine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(20, 0, 0));
lostNodeMachine.pointerMove(pointer(20, 10, 0));
assert.deepEqual(lostNodeMachine.lostPointerCapture({ pointerId: 20 }), [
{ kind: "node-drag-cancel", nodeId: "node-a", pointerId: 20, reason: "lostpointercapture" }
]);
assert.equal(lostNodeMachine.snapshot(), null);
const clickMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
clickMachine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(15, 0, 0));
assert.deepEqual(clickMachine.pointerCancel({ pointerId: 15 }), []);
assert.deepEqual(clickMachine.pointerUp(pointer(15, 0, 0)), []);
const panMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
panMachine.pointerDown(classifyGraphPointerDownTarget(blankTarget()), pointer(16, 0, 0));
panMachine.pointerMove(pointer(16, 10, 0));
assert.deepEqual(panMachine.lostPointerCapture({ pointerId: 16 }), [
{ kind: "blank-pan-cancel", pointerId: 16, reason: "lostpointercapture" }
]);
assert.equal(panMachine.snapshot(), null);
});
it("lets Escape cancel active drag, community, and pan gestures", () => {
const dragMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
dragMachine.pointerDown(classifyGraphPointerDownTarget(nodeTarget("node-a")), pointer(17, 0, 0));
dragMachine.pointerMove(pointer(17, 10, 0));
assert.deepEqual(dragMachine.escape(), [
{ kind: "node-drag-cancel", nodeId: "node-a", pointerId: 17, reason: "escape" }
]);
const communityMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
communityMachine.pointerDown(classifyGraphPointerDownTarget(communityWashTarget("community-a")), pointer(18, 0, 0));
assert.deepEqual(communityMachine.escape(), [
{ kind: "community-click-cancelled", communityId: "community-a", pointerId: 18, reason: "escape" }
]);
const panMachine = new GraphGestureStateMachine({ dragThreshold: 4 });
panMachine.pointerDown(classifyGraphPointerDownTarget(blankTarget()), pointer(19, 0, 0));
panMachine.pointerMove(pointer(19, 10, 0));
assert.deepEqual(panMachine.escape(), [
{ kind: "blank-pan-cancel", pointerId: 19, reason: "escape" }
]);
});
});
describe("graph gesture controller", () => {
it("owns root wheel and pointer event bindings and releases them on destroy", () => {
const root = new FakeGestureRoot();
const zoomed: Array<{ target: string; deltaY: number }> = [];
const intents: GraphGestureIntent[] = [];
const activeSnapshots: unknown[] = [];
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
onWheelZoom: (event, decision) => {
event.preventDefault();
zoomed.push({ target: decision.target.kind, deltaY: event.deltaY });
},
onGestureIntents: (nextIntents) => {
intents.push(...nextIntents);
},
onActiveStateChange: (active) => {
activeSnapshots.push(active);
}
});
const wheel = wheelDomEvent(nodeTarget("node-a"), 24);
root.dispatch("wheel", wheel);
assert.equal(wheel.defaultPrevented, true);
assert.deepEqual(zoomed, [{ target: "node", deltaY: 24 }]);
const pointerDown = pointerDomEvent(nodeTarget("node-a"), 21, 10, 10);
root.dispatch("pointerdown", pointerDown);
assert.equal(pointerDown.defaultPrevented, true);
assert.equal(root.hasPointerCapture(21), true);
const pointerMove = pointerDomEvent(nodeTarget("node-a"), 21, 20, 18);
root.dispatch("pointermove", pointerMove);
assert.equal(pointerMove.defaultPrevented, true);
const pointerUp = pointerDomEvent(nodeTarget("node-a"), 21, 20, 18);
root.dispatch("pointerup", pointerUp);
assert.equal(pointerUp.defaultPrevented, true);
assert.equal(root.hasPointerCapture(21), false);
assert.deepEqual(intents, [
{ kind: "node-drag-start", nodeId: "node-a", pointerId: 21, screenPoint: { x: 20, y: 18 } },
{ kind: "node-drag-move", nodeId: "node-a", pointerId: 21, screenPoint: { x: 20, y: 18 }, delta: { x: 10, y: 8 } },
{ kind: "node-drag-end", nodeId: "node-a", pointerId: 21, screenPoint: { x: 20, y: 18 } }
]);
assert.ok(activeSnapshots.length >= 3);
controller.destroy();
root.dispatch("wheel", wheelDomEvent(nodeTarget("node-b"), 12));
assert.deepEqual(zoomed, [{ target: "node", deltaY: 24 }]);
});
it("prevents browser default zoom for shortcut wheels over graph-owned targets", () => {
const root = new FakeGestureRoot();
const zoomed: Array<{ target: string; ctrlKey: boolean; metaKey: boolean }> = [];
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
onWheelZoom: (event, decision) => {
event.preventDefault();
zoomed.push({ target: decision.target.kind, ctrlKey: event.ctrlKey, metaKey: event.metaKey });
},
onGestureIntents: () => {}
});
const nodeWheel = wheelDomEvent(nodeTarget("node-a"), -18, { ctrlKey: true });
root.dispatch("wheel", nodeWheel);
assert.equal(nodeWheel.defaultPrevented, true);
const blankWheel = wheelDomEvent(blankTarget(), -12, { metaKey: true });
root.dispatch("wheel", blankWheel);
assert.equal(blankWheel.defaultPrevented, true);
const searchWheel = wheelDomEvent(controlTarget(".graph-search"), -8, { ctrlKey: true });
root.dispatch("wheel", searchWheel);
assert.equal(searchWheel.defaultPrevented, false);
assert.deepEqual(zoomed, [
{ target: "node", ctrlKey: true, metaKey: false },
{ target: "graph-blank", ctrlKey: false, metaKey: true }
]);
controller.destroy();
});
it("uses spatial graph targets for graph-owned DOM instead of DOM stacking order", () => {
const root = new FakeGestureRoot();
const zoomed: GraphGestureTarget[] = [];
const pointerDecisions: GraphGestureTarget[] = [];
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
graphTargetFromScreenPoint: () => ({ kind: "node", id: "node-from-spatial-index" }),
onWheelZoom: (_event, decision) => {
zoomed.push(decision.target);
},
onPointerDown: (_event, decision) => {
pointerDecisions.push(decision.target);
},
onGestureIntents: () => {}
});
const stackedCommunity = communityWashTarget("community-from-dom");
const wheel = wheelDomEvent(stackedCommunity, -20);
root.dispatch("wheel", wheel);
assert.equal(wheel.defaultPrevented, true);
const pointerDown = pointerDomEvent(stackedCommunity, 51, 260, 220);
root.dispatch("pointerdown", pointerDown);
assert.equal(pointerDown.defaultPrevented, true);
assert.equal(root.hasPointerCapture(51), true);
assert.deepEqual(zoomed, [{ kind: "node", id: "node-from-spatial-index" }]);
assert.deepEqual(pointerDecisions, [{ kind: "node", id: "node-from-spatial-index" }]);
controller.destroy();
});
it("keeps aggregation container DOM ownership above spatial hits", () => {
const root = new FakeGestureRoot();
const zoomed: GraphGestureTarget[] = [];
const pointerDecisions: GraphGestureTarget[] = [];
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
graphTargetFromScreenPoint: () => ({ kind: "node", id: "node-behind-container" }),
onWheelZoom: (_event, decision) => {
zoomed.push(decision.target);
},
onPointerDown: (_event, decision) => {
pointerDecisions.push(decision.target);
},
onGestureIntents: () => {}
});
const container = aggregationContainerTarget("agg-t1", "t1");
const wheel = wheelDomEvent(container, -20);
root.dispatch("wheel", wheel);
assert.equal(wheel.defaultPrevented, true);
const pointerDown = pointerDomEvent(container, 52, 260, 220);
root.dispatch("pointerdown", pointerDown);
assert.equal(pointerDown.defaultPrevented, true);
assert.equal(root.hasPointerCapture(52), true);
assert.deepEqual(zoomed, [{ kind: "aggregation-container", id: "agg-t1", communityId: "t1" }]);
assert.deepEqual(pointerDecisions, [{ kind: "community-wash", id: "t1" }]);
controller.destroy();
});
it("uses DOM blank ownership for return-global double-click while graph objects and blockers keep defaults", () => {
const root = new FakeGestureRoot();
let resetCount = 0;
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
graphTargetFromScreenPoint: () => ({ kind: "node", id: "node-from-spatial-index" }),
onWheelZoom: () => {},
onGestureIntents: () => {},
onBlankDoubleClick: () => {
resetCount += 1;
}
});
const blankDoubleClick = mouseDomEvent(blankTarget(), 10, 40);
root.dispatch("dblclick", blankDoubleClick);
assert.equal(blankDoubleClick.defaultPrevented, true);
assert.equal(resetCount, 1);
const svgBlankDoubleClick = mouseDomEvent(graphBlankTarget(), 12, 42);
root.dispatch("dblclick", svgBlankDoubleClick);
assert.equal(svgBlankDoubleClick.defaultPrevented, true);
assert.equal(resetCount, 2);
const blankSecondClick = mouseDomEvent(graphBlankTarget(), 12, 42, { detail: 2, timeStamp: 2000 });
root.dispatch("click", blankSecondClick);
assert.equal(blankSecondClick.defaultPrevented, true);
assert.equal(resetCount, 3);
const duplicateDblClick = mouseDomEvent(graphBlankTarget(), 12, 42, { detail: 2, timeStamp: 2100 });
root.dispatch("dblclick", duplicateDblClick);
assert.equal(duplicateDblClick.defaultPrevented, false);
assert.equal(resetCount, 3);
const communityDoubleClick = mouseDomEvent(communityWashTarget("community-from-dom"), 10, 40);
root.dispatch("dblclick", communityDoubleClick);
assert.equal(communityDoubleClick.defaultPrevented, false);
assert.equal(resetCount, 3);
const edgeDoubleClick = mouseDomEvent(edgeTarget("edge-from-dom"), 20, 40);
root.dispatch("dblclick", edgeDoubleClick);
assert.equal(edgeDoubleClick.defaultPrevented, false);
assert.equal(resetCount, 3);
const edgeThenBlankPointerDown = pointerDomEvent(edgeTarget("edge-from-dom"), 61, 24, 44, { timeStamp: 3000 });
root.dispatch("pointerdown", edgeThenBlankPointerDown);
root.dispatch("pointerup", pointerDomEvent(graphBlankTarget(), 61, 24, 44, { timeStamp: 3020 }));
const edgeThenBlankClick = mouseDomEvent(blankTarget(), 24, 44, { detail: 2, timeStamp: 3040 });
root.dispatch("click", edgeThenBlankClick);
const edgeThenBlankDoubleClick = mouseDomEvent(blankTarget(), 24, 44, { detail: 2, timeStamp: 3060 });
root.dispatch("dblclick", edgeThenBlankDoubleClick);
assert.equal(edgeThenBlankClick.defaultPrevented, false);
assert.equal(edgeThenBlankDoubleClick.defaultPrevented, false);
assert.equal(resetCount, 3);
const nodeDoubleClick = mouseDomEvent(nodeTarget("node-from-dom"), 30, 40);
root.dispatch("dblclick", nodeDoubleClick);
assert.equal(nodeDoubleClick.defaultPrevented, false);
assert.equal(resetCount, 3);
const searchDoubleClick = mouseDomEvent(controlTarget(".graph-search"), 10, 40);
root.dispatch("dblclick", searchDoubleClick);
assert.equal(searchDoubleClick.defaultPrevented, false);
assert.equal(resetCount, 3);
controller.destroy();
});
it("does not prevent browser defaults over gesture blocker pointer targets", () => {
const root = new FakeGestureRoot();
const intents: GraphGestureIntent[] = [];
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
onWheelZoom: () => {},
onGestureIntents: (nextIntents) => {
intents.push(...nextIntents);
}
});
const blockerCases: Array<[string, GraphGestureTargetLike]> = [
["search", controlTarget(".graph-search")],
["toolbar", controlTarget(".graph-toolbar")],
["drawer", controlTarget(".graph-reader, .graph-selection-panel, [data-graph-drawer=\"true\"]")],
["minimap", controlTarget(".mini-map")],
["text-control", new FakeTarget({ tagName: "input", type: "text" })]
];
for (const [label, target] of blockerCases) {
const pointerDown = pointerDomEvent(target, 31, 10, 10);
root.dispatch("pointerdown", pointerDown);
assert.equal(pointerDown.defaultPrevented, false, `${label} pointerdown should not be owned by graph gestures`);
assert.equal(root.hasPointerCapture(31), false, `${label} pointerdown should not capture the pointer`);
assert.equal(controller.snapshot(), null, `${label} pointerdown should not start an active graph gesture`);
}
assert.deepEqual(intents, []);
controller.destroy();
});
it("cleans up controller-owned active gestures on pointercancel and lostpointercapture", () => {
const root = new FakeGestureRoot();
const intents: GraphGestureIntent[] = [];
const controller = new GraphGestureController(root as unknown as HTMLElement, {
targetFromEventTarget: (target) => target as GraphGestureTargetLike | null,
onWheelZoom: () => {},
onGestureIntents: (nextIntents) => {
intents.push(...nextIntents);
}
});
const nodePointerDown = pointerDomEvent(nodeTarget("node-a"), 41, 10, 10);
root.dispatch("pointerdown", nodePointerDown);
root.dispatch("pointermove", pointerDomEvent(nodeTarget("node-a"), 41, 20, 10));
assert.equal(root.hasPointerCapture(41), true);
const nodeCancel = pointerDomEvent(nodeTarget("node-a"), 41, 20, 10);
root.dispatch("pointercancel", nodeCancel);
assert.equal(nodeCancel.defaultPrevented, true);
assert.equal(root.hasPointerCapture(41), false);
assert.equal(controller.snapshot(), null);
const blankPointerDown = pointerDomEvent(blankTarget(), 42, 100, 100);
root.dispatch("pointerdown", blankPointerDown);
root.dispatch("pointermove", pointerDomEvent(blankTarget(), 42, 116, 100));
assert.equal(root.hasPointerCapture(42), true);
root.releasePointerCapture(42);
root.dispatch("lostpointercapture", pointerDomEvent(blankTarget(), 42, 116, 100));
assert.equal(controller.snapshot(), null);
assert.deepEqual(intents, [
{ kind: "node-drag-start", nodeId: "node-a", pointerId: 41, screenPoint: { x: 20, y: 10 } },
{ kind: "node-drag-move", nodeId: "node-a", pointerId: 41, screenPoint: { x: 20, y: 10 }, delta: { x: 10, y: 0 } },
{ kind: "node-drag-cancel", nodeId: "node-a", pointerId: 41, reason: "pointercancel" },
{ kind: "blank-pan-start", pointerId: 42, screenPoint: { x: 116, y: 100 } },
{ kind: "blank-pan-move", pointerId: 42, screenPoint: { x: 116, y: 100 }, delta: { x: 16, y: 0 } },
{ kind: "blank-pan-cancel", pointerId: 42, reason: "lostpointercapture" }
]);
controller.destroy();
});
});
function blankTarget(): FakeTarget {
return new FakeTarget();
}
function graphBlankTarget(): FakeTarget {
const blank = new FakeTarget({ dataset: { graphBlank: "true" } });
return new FakeTarget({ closest: { "[data-graph-blank=\"true\"]": blank } });
}
function nodeTarget(id: string): FakeTarget {
const node = new FakeTarget({ dataset: { id } });
return new FakeTarget({ closest: { ".node": node } });
}
function communityWashTarget(id: string): FakeTarget {
const wash = new FakeTarget({ dataset: { communityId: id } });
return new FakeTarget({ closest: { ".community-wash": wash } });
}
function aggregationContainerTarget(id: string, communityId: string): FakeTarget {
const container = new FakeTarget({ dataset: { aggregationId: id, communityId } });
return new FakeTarget({ closest: { ".aggregation-container": container } });
}
function edgeTarget(id: string): FakeTarget {
const edge = new FakeTarget({ dataset: { edgeId: id } });
return new FakeTarget({ closest: { ".edge": edge } });
}
function controlTarget(selector: string): FakeTarget {
const control = new FakeTarget();
return new FakeTarget({ closest: { [selector]: control } });
}
function pointer(pointerId: number, x: number, y: number, options: { shiftKey?: boolean } = {}) {
return {
pointerId,
screenPoint: { x, y },
shiftKey: options.shiftKey
};
}
class FakeGestureRoot {
private readonly listeners = new Map<string, Set<(event: any) => void>>();
private readonly pointerCaptures = new Set<number>();
addEventListener(type: string, listener: (event: any) => void): void {
const listeners = this.listeners.get(type) || new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: any) => void): void {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any): void {
for (const listener of this.listeners.get(type) || []) {
listener(event);
}
}
setPointerCapture(pointerId: number): void {
this.pointerCaptures.add(pointerId);
}
hasPointerCapture(pointerId: number): boolean {
return this.pointerCaptures.has(pointerId);
}
releasePointerCapture(pointerId: number): void {
this.pointerCaptures.delete(pointerId);
}
getBoundingClientRect(): { left: number; top: number; width: number; height: number } {
return { left: 0, top: 0, width: 800, height: 600 };
}
}
function wheelDomEvent(
target: GraphGestureTargetLike,
deltaY: number,
options: { ctrlKey?: boolean; metaKey?: boolean } = {}
): WheelEvent & { defaultPrevented: boolean } {
let defaultPrevented = false;
return {
target,
deltaY,
deltaMode: 0,
ctrlKey: options.ctrlKey === true,
metaKey: options.metaKey === true,
preventDefault: () => {
defaultPrevented = true;
},
get defaultPrevented() {
return defaultPrevented;
}
} as WheelEvent & { defaultPrevented: boolean };
}
function pointerDomEvent(
target: GraphGestureTargetLike,
pointerId: number,
clientX: number,
clientY: number,
options: { button?: number; shiftKey?: boolean; timeStamp?: number } = {}
): PointerEvent & { defaultPrevented: boolean } {
let defaultPrevented = false;
return {
target,
pointerId,
clientX,
clientY,
timeStamp: options.timeStamp ?? 0,
button: options.button ?? 0,
shiftKey: options.shiftKey === true,
preventDefault: () => {
defaultPrevented = true;
},
get defaultPrevented() {
return defaultPrevented;
}
} as PointerEvent & { defaultPrevented: boolean };
}
function mouseDomEvent(
target: GraphGestureTargetLike,
clientX: number,
clientY: number,
options: { detail?: number; timeStamp?: number } = {}
): MouseEvent & { defaultPrevented: boolean; propagationStopped: boolean } {
let defaultPrevented = false;
let propagationStopped = false;
return {
target,
clientX,
clientY,
detail: options.detail ?? 1,
timeStamp: options.timeStamp ?? 0,
preventDefault: () => {
defaultPrevented = true;
},
stopPropagation: () => {
propagationStopped = true;
},
get defaultPrevented() {
return defaultPrevented;
},
get propagationStopped() {
return propagationStopped;
}
} as MouseEvent & { defaultPrevented: boolean; propagationStopped: boolean };
}
function matchesSelf(target: FakeTarget, selector: string): boolean {
if (selector.includes("[contenteditable=\"true\"]") && target.isContentEditable) return true;
if (selector.includes("textarea") && target.tagName?.toLowerCase() === "textarea") return true;
if (selector.includes("select") && target.tagName?.toLowerCase() === "select") return true;
if (selector.includes("[data-graph-text-control=\"true\"]") && target.dataset.graphTextControl === "true") return true;
return false;
}