@marimo-team/islands 0.24.1-dev95 → 0.24.1-dev96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,24 @@
2
2
 
3
3
  // @ts-expect-error - vega-parser is not typed
4
4
  import { parse } from "vega-parser";
5
- import { describe, expect, it } from "vitest";
5
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
6
6
  import { invariant } from "@/utils/invariant";
7
7
  import { makeSelectable } from "../make-selectable";
8
- import { getSelectionParamNames } from "../params";
8
+ import { getPanZoomModifier, getSelectionParamNames } from "../params";
9
9
  import type { VegaLiteSpec } from "../types";
10
10
 
11
+ // Pin the platform so the pan/zoom modifier (and therefore the snapshots
12
+ // below) is deterministic regardless of the host running the tests. We use a
13
+ // non-Mac platform, which resolves the modifier to `ctrlKey` — this matches
14
+ // the Linux/Windows environment from issue #3812.
15
+ beforeAll(() => {
16
+ vi.spyOn(window.navigator, "platform", "get").mockReturnValue("Linux x86_64");
17
+ });
18
+
19
+ afterAll(() => {
20
+ vi.restoreAllMocks();
21
+ });
22
+
11
23
  describe("makeSelectable", () => {
12
24
  it("should return correctly if mark is not string", () => {
13
25
  const spec = {
@@ -1268,3 +1280,67 @@ describe("makeSelectable", () => {
1268
1280
  expect(getSelectionParamNames(newSpec)).toEqual(["my_selection"]);
1269
1281
  });
1270
1282
  });
1283
+
1284
+ describe("pan/zoom modifier key (issue #3812)", () => {
1285
+ afterAll(() => {
1286
+ vi.restoreAllMocks();
1287
+ });
1288
+
1289
+ function mockPlatform(platform: string) {
1290
+ // jsdom's navigator has no `userAgentData`, so the detection helper falls
1291
+ // back to `navigator.platform`, which is what we override here.
1292
+ vi.spyOn(window.navigator, "platform", "get").mockReturnValue(platform);
1293
+ }
1294
+
1295
+ it("uses metaKey (Command) on macOS", () => {
1296
+ mockPlatform("MacIntel");
1297
+ expect(getPanZoomModifier()).toBe("metaKey");
1298
+
1299
+ const spec = { mark: "point" } as VegaLiteSpec;
1300
+ const newSpec = makeSelectable(spec, {});
1301
+ const panZoom = newSpec.params?.find((p) => p.name === "pan_zoom");
1302
+ invariant(panZoom && "select" in panZoom, "expected pan_zoom param");
1303
+ const select = panZoom.select as { on: string; zoom: string };
1304
+ expect(select.on).toContain("event.metaKey");
1305
+ expect(select.on).not.toContain("event.ctrlKey");
1306
+ expect(select.zoom).toBe("wheel![event.metaKey]");
1307
+ });
1308
+
1309
+ it.each(["Linux x86_64", "Win32"])(
1310
+ "uses ctrlKey on non-macOS platform %s",
1311
+ (platform) => {
1312
+ mockPlatform(platform);
1313
+ expect(getPanZoomModifier()).toBe("ctrlKey");
1314
+
1315
+ const spec = { mark: "point" } as VegaLiteSpec;
1316
+ const newSpec = makeSelectable(spec, {});
1317
+ const panZoom = newSpec.params?.find((p) => p.name === "pan_zoom");
1318
+ invariant(panZoom && "select" in panZoom, "expected pan_zoom param");
1319
+ const select = panZoom.select as { on: string; zoom: string };
1320
+ // Must not use metaKey, which maps to the Super/Windows key here and is
1321
+ // captured by the OS/desktop environment before the chart sees it.
1322
+ expect(select.on).toContain("event.ctrlKey");
1323
+ expect(select.on).not.toContain("event.metaKey");
1324
+ expect(select.zoom).toBe("wheel![event.ctrlKey]");
1325
+ },
1326
+ );
1327
+
1328
+ it("keeps point/interval selection gated on the same modifier as pan/zoom", () => {
1329
+ mockPlatform("Linux x86_64");
1330
+ const spec = { mark: "bar" } as VegaLiteSpec;
1331
+ const newSpec = makeSelectable(spec, {});
1332
+ const mod = getPanZoomModifier();
1333
+
1334
+ for (const param of newSpec.params ?? []) {
1335
+ if (!("select" in param) || typeof param.select !== "object") {
1336
+ continue;
1337
+ }
1338
+ const select = param.select as { on?: string; translate?: string };
1339
+ // The selection gating and pan/zoom must agree on the modifier, otherwise
1340
+ // one gesture triggers both.
1341
+ if (typeof select.on === "string" && select.on.includes("event.")) {
1342
+ expect(select.on).toContain(`event.${mod}`);
1343
+ }
1344
+ }
1345
+ });
1346
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { TopLevelSpec } from "vega-lite";
4
4
  import type { NonNormalizedSpec } from "vega-lite/types_unstable/spec/index.js";
5
+ import { isPlatformMac } from "@/core/hotkeys/shortcuts";
5
6
  import { uniq } from "@/utils/arrays";
6
7
  import { Marks } from "./marks";
7
8
  import {
@@ -51,6 +52,21 @@ export const ParamNames = {
51
52
  },
52
53
  };
53
54
 
55
+ /**
56
+ * The keyboard modifier used to gate pan/zoom (and to exclude it from
57
+ * point/interval selection).
58
+ *
59
+ * On macOS we use the Command key (`metaKey`), which is conflict-free. On other
60
+ * platforms `metaKey` maps to the Super/Windows key, which GNOME/Wayland and
61
+ * Windows reserve for system actions (workspace switching, the activities
62
+ * overview, etc.), so pan/zoom never reaches the chart. We fall back to the
63
+ * Control key there, matching the common convention for chart pan/zoom on
64
+ * Linux and Windows. See https://github.com/marimo-team/marimo/issues/3812.
65
+ */
66
+ export function getPanZoomModifier(): "metaKey" | "ctrlKey" {
67
+ return isPlatformMac() ? "metaKey" : "ctrlKey";
68
+ }
69
+
54
70
  export const Params = {
55
71
  highlight() {
56
72
  return {
@@ -62,6 +78,7 @@ export const Params = {
62
78
  spec: VegaLiteUnitSpec,
63
79
  layerNum: number | undefined,
64
80
  ): SelectionParameter<"interval"> {
81
+ const mod = getPanZoomModifier();
65
82
  return {
66
83
  name: ParamNames.interval(layerNum),
67
84
  select: {
@@ -73,10 +90,9 @@ export const Params = {
73
90
  stroke: "#669EFF",
74
91
  strokeOpacity: 0.4,
75
92
  },
76
- // So this does not conflict with pan/zoom via metaKey
77
- on: "[mousedown[!event.metaKey], mouseup] > mousemove[!event.metaKey]",
78
- translate:
79
- "[mousedown[!event.metaKey], mouseup] > mousemove[!event.metaKey]",
93
+ // So this does not conflict with pan/zoom, which uses the same modifier
94
+ on: `[mousedown[!event.${mod}], mouseup] > mousemove[!event.${mod}]`,
95
+ translate: `[mousedown[!event.${mod}], mouseup] > mousemove[!event.${mod}]`,
80
96
  },
81
97
  };
82
98
  },
@@ -89,7 +105,7 @@ export const Params = {
89
105
  select: {
90
106
  type: "point",
91
107
  encodings: getEncodingAxisForMark(spec),
92
- on: "click[!event.metaKey]",
108
+ on: `click[!event.${getPanZoomModifier()}]`,
93
109
  },
94
110
  };
95
111
  },
@@ -103,7 +119,7 @@ export const Params = {
103
119
  name: ParamNames.binColoring(layerNum),
104
120
  select: {
105
121
  type: "point",
106
- on: "click[!event.metaKey]",
122
+ on: `click[!event.${getPanZoomModifier()}]`,
107
123
  },
108
124
  };
109
125
  },
@@ -118,15 +134,15 @@ export const Params = {
118
134
  };
119
135
  },
120
136
  panZoom(): SelectionParameter<"interval"> {
137
+ const mod = getPanZoomModifier();
121
138
  return {
122
139
  name: ParamNames.PAN_ZOOM,
123
140
  bind: "scales",
124
141
  select: {
125
142
  type: "interval",
126
- on: "[mousedown[event.metaKey], window:mouseup] > window:mousemove!",
127
- translate:
128
- "[mousedown[event.metaKey], window:mouseup] > window:mousemove!",
129
- zoom: "wheel![event.metaKey]",
143
+ on: `[mousedown[event.${mod}], window:mouseup] > window:mousemove!`,
144
+ translate: `[mousedown[event.${mod}], window:mouseup] > window:mousemove!`,
145
+ zoom: `wheel![event.${mod}]`,
130
146
  },
131
147
  };
132
148
  },
@@ -13,6 +13,7 @@ import { tooltipHandler } from "@/components/charts/tooltip";
13
13
  import type { SignalListener } from "@/components/charts/types";
14
14
  import { Alert, AlertTitle } from "@/components/ui/alert";
15
15
  import { Tooltip } from "@/components/ui/tooltip";
16
+ import { isPlatformMac } from "@/core/hotkeys/shortcuts";
16
17
  import { useAsyncData } from "@/hooks/useAsyncData";
17
18
  import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
18
19
  import { useTheme } from "@/theme/useTheme";
@@ -229,9 +230,13 @@ const LoadedVegaComponent = ({
229
230
  }
230
231
 
231
232
  if (ParamNames.hasPanZoom(names)) {
233
+ // On macOS pan/zoom is gated behind the Command key; elsewhere the
234
+ // Super/Windows key is reserved by the OS, so we use Control instead.
235
+ // Keep this in sync with `getPanZoomModifier` in params.ts.
236
+ const modifierKey = isPlatformMac() ? "⌘" : "ctrl";
232
237
  hints.push(
233
- ["Pan", "hold the meta key and drag"],
234
- ["Zoom", "hold the meta key and scroll"],
238
+ ["Pan", `hold the ${modifierKey} key and drag`],
239
+ ["Zoom", `hold the ${modifierKey} key and scroll`],
235
240
  );
236
241
  }
237
242