diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 33cf210763b10..3a1b16c494428 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -34,6 +34,7 @@ import { panelNotificationTrigger, PANEL_NOTIFICATION_TRIGGER, } from './lib'; +import { CopyPngAction } from './lib/actions/copy_png_action'; declare module '../../ui_actions/public' { export interface TriggerContextMapping { @@ -61,7 +62,6 @@ export const bootstrap = (uiActions: UiActionsSetup) => { uiActions.registerTrigger(panelBadgeTrigger); uiActions.registerTrigger(panelNotificationTrigger); - const actionApplyFilter = createFilterAction(); - - uiActions.registerAction(actionApplyFilter); + uiActions.registerAction(createFilterAction()); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, new CopyPngAction()); }; diff --git a/src/plugins/embeddable/public/lib/actions/copy_png_action.ts b/src/plugins/embeddable/public/lib/actions/copy_png_action.ts new file mode 100644 index 0000000000000..3a5ea0b25afeb --- /dev/null +++ b/src/plugins/embeddable/public/lib/actions/copy_png_action.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + UiActionsActionDefinition, + UiActionsActionDefinitionContext as ActionDefinitionContext, +} from 'src/plugins/ui_actions/public'; +import { IEmbeddable } from '..'; + +interface Context { + embeddable: IEmbeddable; +} + +export const COPY_PNG_ACTION = 'COPY_PNG_ACTION'; + +declare const ClipboardItem: any; + +export class CopyPngAction implements UiActionsActionDefinition { + public readonly id = COPY_PNG_ACTION; + public readonly order = 25; + + public readonly getDisplayName = () => 'Copy PNG'; + public readonly getIconType = () => 'copy'; + + public readonly isCompatible = async ( + context: ActionDefinitionContext + ): Promise => { + return !!context.embeddable.toPngBlob; + }; + + public readonly execute = async (context: ActionDefinitionContext): Promise => { + const { embeddable } = context; + if (!embeddable.toPngBlob) return; + const blob = await embeddable.toPngBlob(); + const item = new ClipboardItem({ [blob.type]: blob }); + await (navigator.clipboard as any).write([item]); + }; +} diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index e8aecdba0abc4..10dc8c43406cc 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -211,4 +211,10 @@ export interface IEmbeddable< * List of triggers that this embeddable will execute. */ supportedTriggers(): Array; + + /** + * Generate .png image out of the embeddable. This method is optional, however, + * if it is implemented, the embeddable must return a .png image as a `Blob`. + */ + toPngBlob?(): Promise; } diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 476ca0ec17066..83b8caf43e147 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -31,6 +31,7 @@ export { ActionDefinition as UiActionsActionDefinition, createAction, IncompatibleActionError, + ActionDefinitionContext as UiActionsActionDefinitionContext, } from './actions'; export { buildContextMenuForActions } from './context_menu'; export { Presentable as UiActionsPresentable } from './util'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 4df218a3e94e9..023361956ba90 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -32,7 +32,8 @@ import { import { DOC_TYPE, Document, injectFilterReferences } from '../../persistence'; import { ExpressionWrapper } from './expression_wrapper'; import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { isLensBrushEvent, isLensFilterEvent } from '../../types'; +import { isLensBrushEvent, isLensFilterEvent, isLensControlsEvent } from '../../types'; +import { LensChartControls } from '../../xy_visualization/xy_expression'; export interface LensEmbeddableConfiguration { expression: string | null; @@ -63,6 +64,7 @@ export class Embeddable extends AbstractEmbeddable .toPngBlob() not available.'); + return await this.chartControls.toPngBlob(); + } } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index edb787d9ec1a1..b702e102636d3 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -23,6 +23,7 @@ import { TriggerContext, VALUE_CLICK_TRIGGER, } from '../../../../src/plugins/ui_actions/public'; +import { LensChartControls } from './xy_visualization/xy_expression'; export type ErrorCallback = (e: { message: string }) => void; @@ -535,6 +536,10 @@ export interface LensBrushEvent { name: 'brush'; data: TriggerContext['data']; } +export interface LensControlsEvent { + name: 'controls'; + data: LensChartControls; +} export function isLensFilterEvent(event: ExpressionRendererEvent): event is LensFilterEvent { return event.name === 'filter'; @@ -544,11 +549,15 @@ export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensB return event.name === 'brush'; } +export function isLensControlsEvent(event: ExpressionRendererEvent): event is LensControlsEvent { + return event.name === 'controls'; +} + /** * Expression renderer handlers specifically for lens renderers. This is a narrowed down * version of the general render handlers, specifying supported event types. If this type is * used, dispatched events will be handled correctly. */ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers { - event: (event: LensFilterEvent | LensBrushEvent) => void; + event: (event: LensFilterEvent | LensBrushEvent | LensControlsEvent) => void; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 9379c8a612eb2..946bcd2caec1a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import moment from 'moment'; import { @@ -67,6 +67,7 @@ type XYChartRenderProps = XYChartProps & { histogramBarTarget: number; onClickValue: (data: LensFilterEvent['data']) => void; onSelectRange: (data: LensBrushEvent['data']) => void; + onControls?: (controls: LensChartControls) => void; }; export const xyChart: ExpressionFunctionDefinition< @@ -186,6 +187,9 @@ export const getXyChartRenderer = (dependencies: { histogramBarTarget={dependencies.histogramBarTarget} onClickValue={onClickValue} onSelectRange={onSelectRange} + onControls={(data) => { + handlers.event({ name: 'controls', data }); + }} /> , domNode, @@ -218,6 +222,16 @@ export function XYChartReportable(props: XYChartRenderProps) { ); } +export interface LensChartControls { + toPngBlob?: () => Promise; +} + +const toBlob = async (blobOrUrl: Blob | string): Promise => { + if (typeof blobOrUrl === 'object') return blobOrUrl; + const response = await fetch(blobOrUrl); + return await response.blob(); +}; + export function XYChart({ data, args, @@ -227,10 +241,27 @@ export function XYChart({ histogramBarTarget, onClickValue, onSelectRange, + onControls, }: XYChartRenderProps) { const { legend, layers, fittingFunction, gridlinesVisibilitySettings } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const chartRef = React.useRef(null); + + React.useEffect(() => { + if (!onControls) return; + const controls: LensChartControls = { + toPngBlob: async () => { + const snapshot = chartRef.current!.getPNGSnapshot({ + backgroundColor: 'white', + pixelRatio: 2, + }); + if (!snapshot) throw new Error('Could not generate PNG.'); + return toBlob(snapshot.blobOrDataUrl); + }, + }; + onControls(controls); + }, [onControls]); const filteredLayers = layers.filter(({ layerId, xAccessor, accessors }) => { return !( @@ -352,7 +383,7 @@ export function XYChart({ }; return ( - +