@xh/hoist 74.0.0-SNAPSHOT.1749232318711 → 74.0.0-SNAPSHOT.1749597895949

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.
package/CHANGELOG.md CHANGED
@@ -10,12 +10,18 @@
10
10
  * Added `ViewManagerModel.preserveUnsavedChanges` flag to opt-out of that behaviour.
11
11
  * Added `PersistOptions.settleTime` to configure time to wait for state to settle before persisting.
12
12
  * Support for gridcolumn level `onCellClicked` events.
13
+ * General improvements to `MenuItem` api
14
+ * New `MenuContext` object now sent as 2nd arg to `actionFn` and `prepareFn`.
15
+ * Chart context menu is now fully customizable.
16
+ ⚠️ NOTE: if a chart's context menu is turned off with `showContextMenu: false`,
17
+ update to `contextMenu: false`.
13
18
 
14
19
  ### 🐞 Bug Fixes
15
20
  * Improved `ViewManagerModel.settleTime` by delegating to individual `PersistenceProviders`.
16
21
  * Fixed bug where grid column state could become unintentionally dirty when columns were hidden.
17
22
  * Improved `WebsocketService` heartbeat detection to auto-reconnect when the socket reports as open
18
23
  and heartbeats can be sent, but no heartbeat acknowledgements are being received from the server.
24
+ * Restored zoom out with mouse right-to-left drag on Charts.
19
25
 
20
26
  ## v73.0.1 - 2025-05-19
21
27
 
@@ -1,11 +1,14 @@
1
+ import type { ChartContextMenuSpec, ChartMenuToken } from '@xh/hoist/cmp/chart/Types';
1
2
  import { HoistModel, PlainObject, Some } from '@xh/hoist/core';
2
3
  interface ChartConfig {
3
4
  /** The initial highchartsConfig for this chart. */
4
5
  highchartsConfig: PlainObject;
5
6
  /** The initial data series to be displayed. */
6
7
  series?: Some<any>;
7
- /** True to showContextMenu. Defaults to true. Desktop only. */
8
- showContextMenu?: boolean;
8
+ /**
9
+ * True (default) to show default ContextMenu. Supported on desktop only.
10
+ */
11
+ contextMenu?: ChartContextMenuSpec;
9
12
  /** @internal */
10
13
  xhImpl?: boolean;
11
14
  }
@@ -15,7 +18,8 @@ interface ChartConfig {
15
18
  export declare class ChartModel extends HoistModel {
16
19
  highchartsConfig: PlainObject;
17
20
  series: any[];
18
- showContextMenu: boolean;
21
+ contextMenu: ChartContextMenuSpec;
22
+ static defaultContextMenu: ChartMenuToken[];
19
23
  /**
20
24
  * The HighCharts instance currently being displayed. This may be used for reading
21
25
  * information about the chart, but any mutations to the chart should
@@ -44,5 +48,6 @@ export declare class ChartModel extends HoistModel {
44
48
  setSeries(series: any | any[]): void;
45
49
  /** Remove all series from this chart. */
46
50
  clear(): void;
51
+ private parseContextMenu;
47
52
  }
48
53
  export {};
@@ -0,0 +1,20 @@
1
+ import { ChartModel } from '@xh/hoist/cmp/chart/ChartModel';
2
+ import type { ContextMenuSpec, MenuContext, MenuToken } from '@xh/hoist/core';
3
+ /**
4
+ * Highcharts supported tokens {@link https://api.highcharts.com/highcharts/exporting.buttons.contextButton.menuItems}
5
+ * plus Hoist's `copyToClipboard`.
6
+ */
7
+ export type ChartMenuToken = 'viewFullscreen' | 'printChart' | 'downloadJPEG' | 'downloadPNG' | 'downloadSVG' | 'downloadCSV' | 'downloadXLS' | 'downloadPDF' | 'copyToClipboard' | MenuToken;
8
+ export interface ChartMenuContext extends MenuContext {
9
+ chartModel: ChartModel;
10
+ /**
11
+ * Single point is the active series point the mouse is closest to
12
+ */
13
+ point: any;
14
+ /**
15
+ * Points array is the list of points hovered over in each series. When
16
+ * there are multiple series and tooltip.shared = true, points.length less than 1.
17
+ */
18
+ points: any[];
19
+ }
20
+ export type ChartContextMenuSpec = ContextMenuSpec<ChartMenuToken, ChartMenuContext>;
@@ -0,0 +1,4 @@
1
+ import type { ChartMenuContext, ChartMenuToken } from '@xh/hoist/cmp/chart/Types';
2
+ import { type MenuItem, type MenuItemLike } from '@xh/hoist/core';
3
+ /** @internal */
4
+ export declare function getContextMenuItems(items: MenuItemLike<ChartMenuToken>[], context: ChartMenuContext): (MenuItem<ChartMenuToken, ChartMenuContext> | '-')[];
@@ -211,12 +211,35 @@ export interface TrackOptions {
211
211
  /** Optional flag to omit sending message. */
212
212
  omit?: Thunkable<boolean>;
213
213
  }
214
+ /**
215
+ * The base `MenuToken` type. '-' is interpreted as the standard textless divider.
216
+ * Components will likely extend this type to support other strings like 'copyToClipboard',
217
+ * 'print', etc. which the component then converts into a {@link MenuItem}.
218
+ */
219
+ export type MenuToken = '-';
220
+ /**
221
+ * `MenuContext` is the set of contextual arguments passed to a {@link MenuItem}'s
222
+ * `actionFn` and `prepareFn`. `contextMenuEvent` is the right click event that opened the
223
+ * context menu. It is optional because the `contextMenu` component can also be used on
224
+ * popover buttons, where there is no `contextMenuEvent`.
225
+ *
226
+ * Components offering a built-in {@link contextMenu} can extend `MenuContext` to add values
227
+ * relevant to the component. See for example {@link ChartMenuContext}.
228
+ */
229
+ export interface MenuContext {
230
+ contextMenuEvent?: MouseEvent | PointerEvent;
231
+ }
232
+ /**
233
+ * A context menu is specified as an array of items, a function to generate one from a click, or
234
+ * a full element representing a contextMenu Component.
235
+ */
236
+ export type ContextMenuSpec<T = MenuToken, C = MenuContext> = MenuItemLike<T, C>[] | ((e: MouseEvent | PointerEvent, context: C) => MenuItemLike<T, C>[]) | boolean;
214
237
  /**
215
238
  * Basic interface for a MenuItem to appear in a menu.
216
239
  *
217
240
  * MenuItems can be displayed within a context menu, or shown when clicking on a button.
218
241
  */
219
- export interface MenuItem {
242
+ export interface MenuItem<T = MenuToken, C = MenuContext> {
220
243
  /** Label to be displayed. */
221
244
  text: ReactNode;
222
245
  /** Icon to be displayed. */
@@ -226,11 +249,11 @@ export interface MenuItem {
226
249
  /** Css class name to be added when rendering the menu item. */
227
250
  className?: string;
228
251
  /** Executed when the user clicks the menu item. */
229
- actionFn?: (e: MouseEvent | PointerEvent) => void;
252
+ actionFn?: (e: MouseEvent | PointerEvent, context?: C) => void;
230
253
  /** Executed before the item is shown. Use to adjust properties dynamically. */
231
- prepareFn?: (me: MenuItem) => void;
254
+ prepareFn?: (me: MenuItem<T, C>, context?: C) => void;
232
255
  /** Child menu items. */
233
- items?: MenuItemLike[];
256
+ items?: MenuItemLike<T, C>[];
234
257
  /** True to disable this item. */
235
258
  disabled?: boolean;
236
259
  /** True to hide this item. May be set dynamically via prepareFn. */
@@ -240,12 +263,12 @@ export interface MenuItem {
240
263
  }
241
264
  /**
242
265
  * An item that can exist in a Menu.
243
- *
244
- * Allows for a ReactNode as divider. If strings are specified, the implementations may choose
245
- * an appropriate default display, with '-' providing a standard textless divider that will also
246
- * be de-duped if appearing at the beginning, or end, or adjacent to another divider at render time.
266
+ * Components may accept token strings, in addition, '-' will be interpreted as the standard
267
+ * textless divider that will also be de-duped if appearing at the beginning, or end, or adjacent
268
+ * to another divider at render time. Also allows for a ReactNode for flexible display.
247
269
  */
248
- export type MenuItemLike = MenuItem | ReactNode;
270
+ export type MenuItemLike<T = MenuToken, C = MenuContext> = MenuItem<T, C> | T | ReactElement;
271
+ export declare function isMenuItem<T, C>(item: MenuItemLike<T, C>): item is MenuItem<T, C>;
249
272
  /**
250
273
  * An option to be passed to Select controls
251
274
  */
@@ -1,13 +1,8 @@
1
- import { HoistProps, MenuItemLike } from '@xh/hoist/core';
1
+ import { HoistProps, MenuContext, MenuItemLike } from '@xh/hoist/core';
2
2
  import '@xh/hoist/desktop/register';
3
- import { ReactElement } from 'react';
4
- /**
5
- * A context menu is specified as an array of items, a function to generate one from a click, or
6
- * a full element representing a contextMenu Component.
7
- */
8
- export type ContextMenuSpec = MenuItemLike[] | ((e: MouseEvent) => MenuItemLike[]) | ReactElement;
9
3
  export interface ContextMenuProps extends HoistProps {
10
4
  menuItems: MenuItemLike[];
5
+ context?: MenuContext;
11
6
  }
12
7
  /**
13
8
  * Component for a right-click context menu. Not typically used directly by applications - use
@@ -1,3 +1,4 @@
1
+ import { type MenuItemLike } from '@xh/hoist/core';
1
2
  /**
2
3
  * Used to create view menu items (for adding or replacing views)
3
4
  * @internal
@@ -7,4 +8,4 @@ export declare function createViewMenuItems({ dashCanvasModel, position, viewId,
7
8
  position?: any;
8
9
  viewId?: any;
9
10
  replaceExisting?: boolean;
10
- }): any[];
11
+ }): MenuItemLike[];
@@ -1,8 +1,7 @@
1
- import { BoxProps, HoistProps, Some, TaskObserver } from '@xh/hoist/core';
1
+ import { BoxProps, HoistProps, Some, TaskObserver, type ContextMenuSpec } from '@xh/hoist/core';
2
2
  import '@xh/hoist/desktop/register';
3
3
  import { HotkeyConfig } from '@xh/hoist/kit/blueprint';
4
4
  import { ReactElement, ReactNode } from 'react';
5
- import { ContextMenuSpec } from '../contextmenu/ContextMenu';
6
5
  import './Panel.scss';
7
6
  import { PanelModel } from './PanelModel';
8
7
  export interface PanelProps extends HoistProps<PanelModel>, Omit<BoxProps, 'title'> {
@@ -1,4 +1,4 @@
1
- import { ContextMenuSpec } from '@xh/hoist/desktop/cmp/contextmenu/ContextMenu';
1
+ import type { ContextMenuSpec } from '@xh/hoist/core';
2
2
  import { ReactElement } from 'react';
3
3
  /**
4
4
  * Hook to add a right-click context menu to a component.
@@ -19,7 +19,6 @@ import {
19
19
  XH
20
20
  } from '@xh/hoist/core';
21
21
  import {useContextMenu} from '@xh/hoist/dynamics/desktop';
22
- import {Icon} from '@xh/hoist/icon';
23
22
  import {Highcharts} from '@xh/hoist/kit/highcharts';
24
23
  import {runInAction} from '@xh/hoist/mobx';
25
24
  import {logError, mergeDeep} from '@xh/hoist/utils/js';
@@ -95,7 +94,8 @@ export const [Chart, chart] = hoistCmp.withFactory<ChartProps>({
95
94
  })
96
95
  });
97
96
 
98
- return !XH.isMobileApp ? useContextMenu(coreContents, impl.contextMenu) : coreContents;
97
+ // Must check isMobileApp here because `useContextMenu` is not defined in mobile
98
+ return !XH.isMobileApp ? useContextMenu(coreContents, model.contextMenu) : coreContents;
99
99
  }
100
100
  });
101
101
 
@@ -106,12 +106,9 @@ class ChartLocalModel extends HoistModel {
106
106
  model: ChartModel;
107
107
 
108
108
  chartRef = createObservableRef<HTMLElement>();
109
- contextMenu: any;
110
109
  prevSeriesConfig;
111
110
 
112
111
  override onLinked() {
113
- this.contextMenu = this.getContextMenu();
114
-
115
112
  this.addReaction({
116
113
  track: () => [
117
114
  this.componentProps.aspectRatio,
@@ -136,7 +133,7 @@ class ChartLocalModel extends HoistModel {
136
133
  return this.model.highchart;
137
134
  }
138
135
 
139
- updateSeries() {
136
+ private updateSeries() {
140
137
  const newSeries = this.model.series,
141
138
  seriesConfig = newSeries.map(it => omit(it, 'data')),
142
139
  {prevSeriesConfig, chart} = this,
@@ -156,7 +153,7 @@ class ChartLocalModel extends HoistModel {
156
153
  this.prevSeriesConfig = seriesConfig;
157
154
  }
158
155
 
159
- renderHighChart() {
156
+ private renderHighChart() {
160
157
  // Chart does not re-render well in fullscreen mode
161
158
  // so just close fullscreen mode if it's open.
162
159
  if (this.chart?.fullscreen?.isOpen) {
@@ -200,7 +197,7 @@ class ChartLocalModel extends HoistModel {
200
197
  }
201
198
  };
202
199
 
203
- getChartDims({width, height}) {
200
+ private getChartDims({width, height}) {
204
201
  const {aspectRatio} = this.componentProps;
205
202
 
206
203
  if (!aspectRatio || aspectRatio <= 0) return {width, height};
@@ -208,7 +205,7 @@ class ChartLocalModel extends HoistModel {
208
205
  return this.applyAspectRatio(width, height, aspectRatio);
209
206
  }
210
207
 
211
- applyAspectRatio(width, height, aspectRatio) {
208
+ private applyAspectRatio(width, height, aspectRatio) {
212
209
  const adjWidth = height * aspectRatio,
213
210
  adjHeight = width / aspectRatio;
214
211
 
@@ -236,7 +233,7 @@ class ChartLocalModel extends HoistModel {
236
233
  super.destroy();
237
234
  }
238
235
 
239
- destroyHighChart() {
236
+ private destroyHighChart() {
240
237
  if (this.chart) {
241
238
  this.chart.destroy();
242
239
  this.chart = null;
@@ -246,7 +243,7 @@ class ChartLocalModel extends HoistModel {
246
243
  //----------------------
247
244
  // Highcharts Config
248
245
  //----------------------
249
- getMergedConfig(): PlainObject {
246
+ private getMergedConfig(): PlainObject {
250
247
  const propsConf = this.getModelConfig(),
251
248
  themeConf = this.getThemeConfig(),
252
249
  defaultConf = this.getDefaultConfig();
@@ -255,7 +252,7 @@ class ChartLocalModel extends HoistModel {
255
252
  return mergeDeep(defaultConf, themeConf, propsConf);
256
253
  }
257
254
 
258
- getDefaultConfig() {
255
+ private getDefaultConfig() {
259
256
  const exporting = {
260
257
  enabled: false,
261
258
  fallbackToExportServer: false,
@@ -329,7 +326,7 @@ class ChartLocalModel extends HoistModel {
329
326
  };
330
327
  }
331
328
 
332
- mergeAxisConfigs(theme, conf) {
329
+ private mergeAxisConfigs(theme, conf) {
333
330
  const axisLabels = ['x', 'y', 'z'];
334
331
  axisLabels.forEach(lbl => {
335
332
  const axis = lbl + 'Axis',
@@ -341,11 +338,11 @@ class ChartLocalModel extends HoistModel {
341
338
  });
342
339
  }
343
340
 
344
- getDefaultAxisConfig(axis) {
341
+ private getDefaultAxisConfig(axis) {
345
342
  const defaults = {
346
343
  xAxis: {
347
344
  // Padding is ignored by setExtremes, so we default to 0 to make things less jumpy when zooming.
348
- // This is especially important when Navigator shown; first reload of data can cause a surprising tiny rezoom.
345
+ // This is especially important when the Navigator is shown. The first reload of data can cause a surprising tiny re-zoom.
349
346
  minPadding: 0,
350
347
  maxPadding: 0,
351
348
  dateTimeLabelFormats: {
@@ -365,11 +362,11 @@ class ChartLocalModel extends HoistModel {
365
362
  return defaults[axis];
366
363
  }
367
364
 
368
- getThemeConfig() {
365
+ private getThemeConfig() {
369
366
  return XH.darkTheme ? cloneDeep(DarkTheme) : cloneDeep(LightTheme);
370
367
  }
371
368
 
372
- getModelConfig() {
369
+ private getModelConfig() {
373
370
  return {
374
371
  ...this.model.highchartsConfig,
375
372
  series: this.model.series
@@ -380,43 +377,4 @@ class ChartLocalModel extends HoistModel {
380
377
  // Handlers
381
378
  //---------------------------
382
379
  onSetExtremes = () => {};
383
-
384
- getContextMenu() {
385
- if (!this.model.showContextMenu || !XH.isDesktop) return null;
386
- return [
387
- {
388
- text: 'View in full screen',
389
- icon: Icon.expand(),
390
- actionFn: () => this.chart.fullscreen.toggle()
391
- },
392
- '-',
393
- {
394
- text: 'Copy to clipboard',
395
- icon: Icon.copy(),
396
- hidden: !Highcharts.isWebKit,
397
- actionFn: () => this.chart.copyToClipboardAsync()
398
- },
399
- {
400
- text: 'Print chart',
401
- icon: Icon.print(),
402
- actionFn: () => this.chart.print()
403
- },
404
- '-',
405
- {
406
- text: 'Download PNG image',
407
- icon: Icon.fileImage(),
408
- actionFn: () => this.chart.exportChartLocal()
409
- },
410
- {
411
- text: 'Download SVG vector image',
412
- icon: Icon.fileImage(),
413
- actionFn: () => this.chart.exportChartLocal({type: 'image/svg+xml'})
414
- },
415
- {
416
- text: 'Export Data',
417
- icon: Icon.fileCsv(),
418
- actionFn: () => this.chart.downloadCSV()
419
- }
420
- ];
421
- }
422
380
  }
@@ -4,9 +4,12 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistModel, PlainObject, Some} from '@xh/hoist/core';
7
+ import {type MouseEvent} from 'react';
8
+ import type {ChartContextMenuSpec, ChartMenuToken} from '@xh/hoist/cmp/chart/Types';
9
+ import {getContextMenuItems} from '@xh/hoist/cmp/chart/impl/ChartContextMenuItems';
10
+ import {HoistModel, PlainObject, Some, XH} from '@xh/hoist/core';
8
11
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
9
- import {castArray, cloneDeep} from 'lodash';
12
+ import {castArray, cloneDeep, isFunction, isNil} from 'lodash';
10
13
  import {mergeDeep} from '@xh/hoist/utils/js';
11
14
 
12
15
  interface ChartConfig {
@@ -16,8 +19,10 @@ interface ChartConfig {
16
19
  /** The initial data series to be displayed. */
17
20
  series?: Some<any>;
18
21
 
19
- /** True to showContextMenu. Defaults to true. Desktop only. */
20
- showContextMenu?: boolean;
22
+ /**
23
+ * True (default) to show default ContextMenu. Supported on desktop only.
24
+ */
25
+ contextMenu?: ChartContextMenuSpec;
21
26
 
22
27
  /** @internal */
23
28
  xhImpl?: boolean;
@@ -33,7 +38,18 @@ export class ChartModel extends HoistModel {
33
38
  @observable.ref
34
39
  series: any[] = [];
35
40
 
36
- showContextMenu: boolean;
41
+ contextMenu: ChartContextMenuSpec;
42
+
43
+ static defaultContextMenu: ChartMenuToken[] = [
44
+ 'viewFullscreen',
45
+ '-',
46
+ 'copyToClipboard',
47
+ 'printChart',
48
+ '-',
49
+ 'downloadPNG',
50
+ 'downloadSVG',
51
+ 'downloadCSV'
52
+ ];
37
53
 
38
54
  /**
39
55
  * The HighCharts instance currently being displayed. This may be used for reading
@@ -47,17 +63,12 @@ export class ChartModel extends HoistModel {
47
63
  super();
48
64
  makeObservable(this);
49
65
 
50
- const {
51
- highchartsConfig,
52
- series = [],
53
- showContextMenu = true,
54
- xhImpl = false
55
- } = config ?? {};
66
+ const {highchartsConfig, series = [], contextMenu, xhImpl = false} = config ?? {};
56
67
 
57
68
  this.xhImpl = xhImpl;
58
69
  this.highchartsConfig = highchartsConfig;
59
70
  this.series = castArray(series);
60
- this.showContextMenu = showContextMenu;
71
+ this.contextMenu = this.parseContextMenu(contextMenu);
61
72
  }
62
73
 
63
74
  /**
@@ -95,4 +106,25 @@ export class ChartModel extends HoistModel {
95
106
  clear() {
96
107
  this.setSeries([]);
97
108
  }
109
+
110
+ private parseContextMenu(spec: ChartContextMenuSpec): ChartContextMenuSpec {
111
+ if (spec === false || !XH.isDesktop) return null;
112
+ if (isNil(spec) || spec === true) spec = ChartModel.defaultContextMenu;
113
+
114
+ return (e: MouseEvent | PointerEvent) => {
115
+ // Convert hoverpoints to points for use in actionFn.
116
+ // Hoverpoints are transient, and change/disappear as mouse moves.
117
+ const getPoint = pt => pt.series?.points.find(it => it.index === pt.index);
118
+ const {hoverPoint, hoverPoints} = this.highchart,
119
+ context = {
120
+ contextMenuEvent: e,
121
+ chartModel: this,
122
+ point: hoverPoint ? getPoint(hoverPoint) : null,
123
+ points: hoverPoints ? hoverPoints.map(getPoint) : []
124
+ },
125
+ items = isFunction(spec) ? spec(e, context) : spec;
126
+
127
+ return getContextMenuItems(items, context);
128
+ };
129
+ }
98
130
  }
@@ -0,0 +1,33 @@
1
+ import {ChartModel} from '@xh/hoist/cmp/chart/ChartModel';
2
+ import type {ContextMenuSpec, MenuContext, MenuToken} from '@xh/hoist/core';
3
+
4
+ /**
5
+ * Highcharts supported tokens {@link https://api.highcharts.com/highcharts/exporting.buttons.contextButton.menuItems}
6
+ * plus Hoist's `copyToClipboard`.
7
+ */
8
+ export type ChartMenuToken =
9
+ | 'viewFullscreen'
10
+ | 'printChart'
11
+ | 'downloadJPEG'
12
+ | 'downloadPNG'
13
+ | 'downloadSVG'
14
+ | 'downloadCSV'
15
+ | 'downloadXLS'
16
+ | 'downloadPDF'
17
+ | 'copyToClipboard'
18
+ | MenuToken;
19
+
20
+ export interface ChartMenuContext extends MenuContext {
21
+ chartModel: ChartModel;
22
+ /**
23
+ * Single point is the active series point the mouse is closest to
24
+ */
25
+ point: any;
26
+ /**
27
+ * Points array is the list of points hovered over in each series. When
28
+ * there are multiple series and tooltip.shared = true, points.length less than 1.
29
+ */
30
+ points: any[];
31
+ }
32
+
33
+ export type ChartContextMenuSpec = ContextMenuSpec<ChartMenuToken, ChartMenuContext>;
@@ -0,0 +1,129 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2025 Extremely Heavy Industries Inc.
6
+ */
7
+ import type {ChartMenuContext, ChartMenuToken} from '@xh/hoist/cmp/chart/Types';
8
+ import {logWarn} from '@xh/hoist/utils/js';
9
+ import {cloneDeep, isEmpty, isString} from 'lodash';
10
+ import {ChartModel} from '@xh/hoist/cmp/chart';
11
+ import {isMenuItem, type MenuItem, type MenuItemLike} from '@xh/hoist/core';
12
+ import {Highcharts} from '@xh/hoist/kit/highcharts';
13
+ import {Icon} from '@xh/hoist/icon';
14
+
15
+ /** @internal */
16
+ export function getContextMenuItems(
17
+ items: MenuItemLike<ChartMenuToken>[],
18
+ context: ChartMenuContext
19
+ ): (MenuItem<ChartMenuToken, ChartMenuContext> | '-')[] {
20
+ return cloneDeep(items).map(it => buildMenuItemConfig(it, context));
21
+ }
22
+
23
+ //---------------------------
24
+ // Implementation
25
+ //---------------------------
26
+ function buildMenuItemConfig(
27
+ item: MenuItemLike<ChartMenuToken, ChartMenuContext>,
28
+ context: ChartMenuContext
29
+ ): MenuItem<ChartMenuToken, ChartMenuContext> | '-' {
30
+ if (isString(item)) return parseToken(item, context.chartModel);
31
+
32
+ // build nested menu item configs
33
+ if (isMenuItem(item)) {
34
+ if (!isEmpty(item.items)) {
35
+ (item.items as (MenuItem<ChartMenuToken, ChartMenuContext> | '-')[]) = item.items.map(
36
+ it => buildMenuItemConfig(it as MenuItemLike, context)
37
+ );
38
+ }
39
+ if (item.actionFn) {
40
+ const fn = item.actionFn;
41
+ item.actionFn = e => fn(e, context);
42
+ }
43
+ if (item.prepareFn) {
44
+ const fn = item.prepareFn;
45
+ item.prepareFn = item => fn(item, context);
46
+ }
47
+ }
48
+
49
+ return item as MenuItem<ChartMenuToken, ChartMenuContext>;
50
+ }
51
+
52
+ function parseToken(
53
+ token: string,
54
+ chartModel: ChartModel
55
+ ): MenuItem<ChartMenuToken, ChartMenuContext> | '-' {
56
+ switch (token) {
57
+ case 'viewFullscreen':
58
+ return {
59
+ text: 'View in full screen',
60
+ icon: Icon.expand(),
61
+ actionFn: () => chartModel.highchart.fullscreen.toggle()
62
+ };
63
+ case 'copyToClipboard':
64
+ return {
65
+ text: 'Copy to clipboard',
66
+ icon: Icon.copy(),
67
+ hidden: !Highcharts.isWebKit,
68
+ actionFn: () => chartModel.highchart.copyToClipboardAsync()
69
+ };
70
+ case 'printChart':
71
+ return {
72
+ text: 'Print chart',
73
+ icon: Icon.print(),
74
+ actionFn: () => chartModel.highchart.print()
75
+ };
76
+ case 'downloadJPEG':
77
+ return {
78
+ text: 'Download JPEG image',
79
+ icon: Icon.fileImage(),
80
+ actionFn: () =>
81
+ chartModel.highchart.exportChartLocal({
82
+ type: 'image/jpeg'
83
+ })
84
+ };
85
+ case 'downloadPNG':
86
+ return {
87
+ text: 'Download PNG image',
88
+ icon: Icon.fileImage(),
89
+ actionFn: () => chartModel.highchart.exportChartLocal()
90
+ };
91
+ case 'downloadSVG':
92
+ return {
93
+ text: 'Download SVG vector image',
94
+ icon: Icon.fileImage(),
95
+ actionFn: () =>
96
+ chartModel.highchart.exportChartLocal({
97
+ type: 'image/svg+xml'
98
+ })
99
+ };
100
+ case 'downloadPDF':
101
+ return {
102
+ text: 'Download PDF',
103
+ icon: Icon.fileImage(),
104
+ actionFn: () =>
105
+ chartModel.highchart.exportChartLocal({
106
+ type: 'application/pdf'
107
+ })
108
+ };
109
+ case 'downloadCSV':
110
+ return {
111
+ text: 'Download CSV',
112
+ icon: Icon.fileCsv(),
113
+ actionFn: () => chartModel.highchart.downloadCSV()
114
+ };
115
+ case 'downloadXLS':
116
+ return {
117
+ text: 'Download Excel',
118
+ icon: Icon.fileExcel(),
119
+ actionFn: () => chartModel.highchart.downloadXLS()
120
+ };
121
+ case '-':
122
+ return '-';
123
+ default:
124
+ logWarn(
125
+ `Invalid ChartMenuToken "${token}" will not be used.`,
126
+ 'ChartContextMenuItem.ts:parseToken'
127
+ );
128
+ }
129
+ }
@@ -30,7 +30,8 @@ export function installZoomoutGesture(Highcharts) {
30
30
 
31
31
  Highcharts.addEvent(this, 'selection', e => {
32
32
  if (pixelDiff < 0) {
33
- this.zoom(); // call w/o arguments resets zoom
33
+ // `undefined`'s preserve defaults, last arg `false` disables animation
34
+ this.xAxis?.forEach(it => it.setExtremes(undefined, undefined, undefined, false));
34
35
  e.preventDefault();
35
36
  }
36
37
  });