Add an option to copy/paste panels between maps
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useAssistant, createAssistantContextItem } from '@grafana/assistant';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -13,13 +13,14 @@ import prometheusLogoSvg from 'app/plugins/datasource/prometheus/img/prometheus_
|
||||
import tempoLogoSvg from 'app/plugins/datasource/tempo/img/tempo_logo.svg';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { addPanel, addFrame, addPostItNote, setCursorMode } from '../state/crdtSlice';
|
||||
import { addPanel, addFrame, addPostItNote, pastePanel, setCursorMode } from '../state/crdtSlice';
|
||||
import {
|
||||
selectPanels,
|
||||
selectMapUid,
|
||||
selectViewport,
|
||||
selectSelectedPanelIds,
|
||||
selectCursorMode,
|
||||
selectClipboard,
|
||||
} from '../state/selectors';
|
||||
|
||||
import { AddPanelAction } from './AssistantComponents';
|
||||
@@ -28,6 +29,7 @@ export function ExploreMapFloatingToolbar() {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasSystemClipboard, setHasSystemClipboard] = useState(false);
|
||||
const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown';
|
||||
|
||||
// Get assistant functionality
|
||||
@@ -39,6 +41,30 @@ export function ExploreMapFloatingToolbar() {
|
||||
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
|
||||
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
|
||||
const cursorMode = useSelector((state) => selectCursorMode(state.exploreMapCRDT));
|
||||
const clipboard = useSelector((state) => selectClipboard(state.exploreMapCRDT));
|
||||
|
||||
// Check system clipboard for panel data when dropdown opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const data = JSON.parse(text);
|
||||
if (data && typeof data === 'object' && 'mode' in data && 'width' in data && 'height' in data) {
|
||||
setHasSystemClipboard(true);
|
||||
} else {
|
||||
setHasSystemClipboard(false);
|
||||
}
|
||||
} catch {
|
||||
setHasSystemClipboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkClipboard();
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddPanel = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -175,6 +201,42 @@ export function ExploreMapFloatingToolbar() {
|
||||
setIsOpen(false);
|
||||
}, [dispatch, currentUsername]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
// Try to read from system clipboard first for cross-canvas support
|
||||
const text = await navigator.clipboard.readText();
|
||||
const clipboardData = JSON.parse(text);
|
||||
|
||||
// Validate clipboard data structure
|
||||
if (clipboardData && typeof clipboardData === 'object' && 'mode' in clipboardData && 'width' in clipboardData && 'height' in clipboardData) {
|
||||
dispatch(
|
||||
pastePanel({
|
||||
viewportSize: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
createdBy: currentUsername,
|
||||
clipboardData,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall through to use local clipboard
|
||||
}
|
||||
|
||||
// Fallback to local clipboard
|
||||
dispatch(
|
||||
pastePanel({
|
||||
viewportSize: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
createdBy: currentUsername,
|
||||
})
|
||||
);
|
||||
}, [dispatch, currentUsername]);
|
||||
|
||||
const handleSetPointerMode = useCallback(() => {
|
||||
dispatch(setCursorMode({ mode: 'pointer' }));
|
||||
}, [dispatch]);
|
||||
@@ -324,6 +386,12 @@ CRITICAL:
|
||||
logoAlt="Pyroscope"
|
||||
onClick={handleAddProfilesDrilldownPanel}
|
||||
/>
|
||||
<MenuItem
|
||||
label={t('explore-map.toolbar.paste-panel', 'Paste panel')}
|
||||
icon="document-info"
|
||||
onClick={handlePaste}
|
||||
disabled={!clipboard && !hasSystemClipboard}
|
||||
/>
|
||||
<MenuItem
|
||||
label={t('explore-map.toolbar.add-sticky', 'Add Sticky note')}
|
||||
icon="file-alt"
|
||||
@@ -338,8 +406,8 @@ CRITICAL:
|
||||
<button
|
||||
className={`${styles.cursorModeButton} ${cursorMode === 'pointer' ? styles.cursorModeButtonActive : ''}`}
|
||||
onClick={handleSetPointerMode}
|
||||
title="Pointer mode (V)"
|
||||
aria-label="Pointer mode"
|
||||
title={t('explore-map.toolbar.pointer-mode', 'Pointer mode (V)')}
|
||||
aria-label={t('explore-map.toolbar.pointer-mode-aria', 'Pointer mode')}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className={styles.cursorIcon}>
|
||||
<path
|
||||
@@ -351,8 +419,8 @@ CRITICAL:
|
||||
<button
|
||||
className={`${styles.cursorModeButton} ${cursorMode === 'hand' ? styles.cursorModeButtonActive : ''}`}
|
||||
onClick={handleSetHandMode}
|
||||
title="Hand mode (H)"
|
||||
aria-label="Hand mode"
|
||||
title={t('explore-map.toolbar.hand-mode', 'Hand mode (H)')}
|
||||
aria-label={t('explore-map.toolbar.hand-mode-aria', 'Hand mode')}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className={styles.cursorIcon}>
|
||||
<path
|
||||
@@ -442,7 +510,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.colors.text.secondary,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { splitClose } from '../../explore/state/main';
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
associatePanelWithFrame,
|
||||
bringPanelToFront,
|
||||
clearActiveDrag,
|
||||
copyPanel,
|
||||
disassociatePanelFromFrame,
|
||||
duplicatePanel,
|
||||
removePanel,
|
||||
@@ -267,8 +270,7 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
() => {
|
||||
// Clean up Explore state first
|
||||
dispatch(splitClose(panel.exploreId));
|
||||
// Then remove panel
|
||||
@@ -278,15 +280,38 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
);
|
||||
|
||||
const handleDuplicate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
() => {
|
||||
dispatch(duplicatePanel({ panelId: panel.id }));
|
||||
},
|
||||
[dispatch, panel.id]
|
||||
);
|
||||
|
||||
const handleInfoClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handleCopy = useCallback(
|
||||
async () => {
|
||||
dispatch(copyPanel({ panelId: panel.id }));
|
||||
|
||||
// Also copy to system clipboard for cross-canvas support
|
||||
try {
|
||||
const panelData = {
|
||||
mode: panel.mode,
|
||||
width: panel.position.width,
|
||||
height: panel.position.height,
|
||||
exploreState: panel.exploreState,
|
||||
iframeUrl: panel.iframeUrl,
|
||||
createdBy: panel.createdBy,
|
||||
};
|
||||
await navigator.clipboard.writeText(JSON.stringify(panelData));
|
||||
dispatch(notifyApp(createSuccessNotification('Panel copied', 'You can paste it on any canvas from the toolbar')));
|
||||
} catch (err) {
|
||||
// Fallback if clipboard API fails
|
||||
dispatch(notifyApp(createSuccessNotification('Panel copied', 'You can paste it from the toolbar on this canvas')));
|
||||
}
|
||||
},
|
||||
[dispatch, panel.id, panel.mode, panel.position, panel.exploreState, panel.iframeUrl, panel.createdBy]
|
||||
);
|
||||
|
||||
const handleInfoClick = useCallback(() => {
|
||||
// Info click doesn't do anything, just shows the description
|
||||
}, []);
|
||||
|
||||
// Build tooltip content for panel info
|
||||
@@ -354,8 +379,13 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
description={getInfoTooltipContent()}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={t('explore-map.panel.duplicate', 'Duplicate panel')}
|
||||
label={t('explore-map.panel.copy', 'Copy panel')}
|
||||
icon="copy"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={t('explore-map.panel.duplicate', 'Duplicate panel')}
|
||||
icon="apps"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -29,7 +29,6 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const maxWaitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const initialLoadDone = useRef(false);
|
||||
const lastSavedCRDTStateRef = useRef<string | null | undefined>(null);
|
||||
const firstChangeTimeRef = useRef<number | null>(null);
|
||||
|
||||
@@ -54,6 +54,16 @@ export interface ExploreMapCRDTState {
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
};
|
||||
clipboard?: {
|
||||
panelData: {
|
||||
mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown';
|
||||
width: number;
|
||||
height: number;
|
||||
exploreState?: SerializedExploreState;
|
||||
iframeUrl?: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +96,7 @@ export function createInitialCRDTState(mapUid?: string): ExploreMapCRDTState {
|
||||
isSyncing: false,
|
||||
activeDrag: undefined,
|
||||
activeFrameDrag: undefined,
|
||||
clipboard: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1144,6 +1155,111 @@ const crdtSlice = createSlice({
|
||||
state.local.activeFrameDrag = undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy a panel to clipboard
|
||||
*/
|
||||
copyPanel: (state, action: PayloadAction<{ panelId: string }>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
const panel = manager.getPanelForUI(action.payload.panelId);
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store panel data in clipboard
|
||||
state.local.clipboard = {
|
||||
panelData: {
|
||||
mode: panel.mode || 'explore',
|
||||
width: panel.position.width,
|
||||
height: panel.position.height,
|
||||
exploreState: panel.exploreState,
|
||||
iframeUrl: panel.iframeUrl,
|
||||
createdBy: panel.createdBy,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Paste a panel from clipboard
|
||||
* This action can be called with clipboardData for pasting from system clipboard
|
||||
*/
|
||||
pastePanel: (state, action: PayloadAction<{
|
||||
viewportSize?: { width: number; height: number };
|
||||
createdBy?: string;
|
||||
clipboardData?: {
|
||||
mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown';
|
||||
width: number;
|
||||
height: number;
|
||||
exploreState?: SerializedExploreState;
|
||||
iframeUrl?: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
}>) => {
|
||||
// Use provided clipboard data or fall back to local clipboard
|
||||
const clipboardData = action.payload.clipboardData || state.local.clipboard?.panelData;
|
||||
|
||||
if (!clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
// Calculate position at viewport center
|
||||
const viewportSize = action.payload.viewportSize || { width: 1920, height: 1080 };
|
||||
const canvasCenterX = (-state.local.viewport.panX + viewportSize.width / 2) / state.local.viewport.zoom;
|
||||
const canvasCenterY = (-state.local.viewport.panY + viewportSize.height / 2) / state.local.viewport.zoom;
|
||||
|
||||
const position = {
|
||||
x: canvasCenterX - clipboardData.width / 2,
|
||||
y: canvasCenterY - clipboardData.height / 2,
|
||||
width: clipboardData.width,
|
||||
height: clipboardData.height,
|
||||
};
|
||||
|
||||
// Create new panel
|
||||
const newPanelId = uuidv4();
|
||||
const newExploreId = generateExploreId();
|
||||
|
||||
const addOperation = manager.createAddPanelOperation(
|
||||
newPanelId,
|
||||
newExploreId,
|
||||
position,
|
||||
clipboardData.mode || 'explore',
|
||||
action.payload.createdBy || clipboardData.createdBy
|
||||
);
|
||||
|
||||
manager.applyOperation(addOperation);
|
||||
state.pendingOperations.push(addOperation);
|
||||
|
||||
// Copy explore state if exists
|
||||
if (clipboardData.exploreState) {
|
||||
const stateOperation = manager.createUpdatePanelExploreStateOperation(
|
||||
newPanelId,
|
||||
clipboardData.exploreState
|
||||
);
|
||||
|
||||
if (stateOperation) {
|
||||
manager.applyOperation(stateOperation);
|
||||
state.pendingOperations.push(stateOperation);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy iframe URL if exists
|
||||
if (clipboardData.iframeUrl) {
|
||||
const urlOperation = manager.createUpdatePanelIframeUrlOperation(
|
||||
newPanelId,
|
||||
clipboardData.iframeUrl
|
||||
);
|
||||
|
||||
if (urlOperation) {
|
||||
manager.applyOperation(urlOperation);
|
||||
state.pendingOperations.push(urlOperation);
|
||||
}
|
||||
}
|
||||
|
||||
saveCRDTManager(state, manager);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (reset)
|
||||
*/
|
||||
@@ -1201,6 +1317,8 @@ export const {
|
||||
clearActiveDrag,
|
||||
setActiveFrameDrag,
|
||||
clearActiveFrameDrag,
|
||||
copyPanel,
|
||||
pastePanel,
|
||||
clearMap,
|
||||
} = crdtSlice.actions;
|
||||
|
||||
|
||||
@@ -345,6 +345,13 @@ export const selectSessionId = (state: ExploreMapCRDTState): string => {
|
||||
return state.sessionId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Select clipboard state
|
||||
*/
|
||||
export const selectClipboard = (state: ExploreMapCRDTState) => {
|
||||
return state.local.clipboard;
|
||||
};
|
||||
|
||||
/**
|
||||
* Select panel count
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user