diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index 33b099d0e..c816ab5a6 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -26,6 +26,7 @@ import type { CanvasSpec, FloatingEdge } from '../../model'; import { isFloatingEdge } from '../../model'; import type { ResizeHandleId } from '../../utils/resizeUtils'; import { nodeBoundingBox } from '../../utils/resizeUtils'; +import { resolveMouseShortcut } from '../../utils/shortcuts'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { DragEdgeLine } from './DragEdgeLine'; import { EditorEdgeItem } from './EditorEdgeItem'; @@ -167,13 +168,17 @@ export function EditorCanvas({ const onSvgDoubleClick = useCallback( (event: MouseEvent): void => { - if (event.ctrlKey || event.metaKey) { - const boundingBox = nodeBoundingBox(displayNodes); - if (boundingBox) { - fitView(boundingBox, width, height); + switch (resolveMouseShortcut(event)?.id) { + case 'fitView': { + const boundingBox = nodeBoundingBox(displayNodes); + if (boundingBox) { + fitView(boundingBox, width, height); + } + return; } - } else { - resetPan(); + case 'resetView': + resetPan(); + return; } }, [displayNodes, fitView, resetPan, width, height], diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 7ae7317ca..a1f22b86f 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Box } from '@mui/material'; import { useChartsTheme } from '@perses-dev/components'; import type { ThresholdOptions } from '@perses-dev/components'; import type { TimeSeries } from '@perses-dev/spec'; @@ -21,13 +22,20 @@ import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; import { useZoom } from '../../hooks/useZoom'; import type { CanvasProps } from '../../model'; import { nodeBoundingBox } from '../../utils/resizeUtils'; +import { resolveMouseShortcut } from '../../utils/shortcuts'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; +import { CanvasShortcutsTooltip } from '../shared/CanvasShortcutsTooltip'; import { PanelEdgeLayer } from './PanelEdgeLayer'; import { PanelNodeLayer } from './PanelNodeLayer'; -import { ThresholdLegend } from './ThresholdLegend'; +import { LEGEND_WIDTH, ThresholdLegend } from './ThresholdLegend'; const SVG_STYLE_BASE = { display: 'block' } as const; const EMPTY_THRESHOLDS: ThresholdOptions = {}; +const PANEL_WRAPPER_SX = { position: 'relative' as const }; + +const LEGEND_MARGIN = 8; +const BTN_MARGIN = 4; +const BTN_HEIGHT = 32; interface PanelSvgProps { svgRef: (node: SVGSVGElement | null) => void; @@ -48,13 +56,15 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg const handleDoubleClick = useCallback( (event: MouseEvent): void => { - if (event.ctrlKey || event.metaKey) { - const boundingBox = nodeBoundingBox(nodes); - if (boundingBox) { - fitView(boundingBox, width, height); + switch (resolveMouseShortcut(event)?.id) { + case 'fitView': { + const boundingBox = nodeBoundingBox(nodes); + if (boundingBox) fitView(boundingBox, width, height); + return; } - } else { - resetPan(); + case 'resetView': + resetPan(); + return; } }, [fitView, resetPan, nodes, width, height], @@ -63,41 +73,52 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg const showLegend = spec.legend !== undefined && spec.thresholds !== undefined; const thresholds = spec.thresholds ?? EMPTY_THRESHOLDS; const legendPosition = spec.legend?.position ?? 'bottom'; - const LEGEND_MARGIN = 8; - const legendX = legendPosition === 'right' ? width - 118 - LEGEND_MARGIN : LEGEND_MARGIN; + const legendX = legendPosition === 'right' ? width - LEGEND_WIDTH - LEGEND_MARGIN : LEGEND_MARGIN; const legendY = - legendPosition === 'right' ? LEGEND_MARGIN : height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24; + legendPosition === 'right' + ? BTN_MARGIN + BTN_HEIGHT + BTN_MARGIN + : height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24; + + const shortcutsBtnSx = useMemo( + () => ({ position: 'absolute' as const, top: BTN_MARGIN, right: BTN_MARGIN, zIndex: 1 }), + [], + ); const svgStyle = useMemo(() => ({ ...SVG_STYLE_BASE, cursor: isPanning ? 'grabbing' : 'grab' }), [isPanning]); return ( - - - - - - - - - {showLegend ? ( - - ) : null} - + + + + + + + + + + {showLegend ? ( + + ) : null} + + + + + ); } diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index 5a675e2ed..b2c5d2bfa 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -24,8 +24,18 @@ const ROW_HEIGHT = 18; const LABEL_OFFSET = SWATCH_SIZE + 6; const PADDING = 8; const FONT_SIZE = 11; +export const LEGEND_WIDTH = 110; +const TEXT_MAX_WIDTH = LEGEND_WIDTH - PADDING - LABEL_OFFSET - PADDING; const NO_SELECT_STYLE = { userSelect: 'none' } as const; +// Approximate character width for font-size 11px sans-serif. +const CHAR_WIDTH_APPROX = 6.5; + +function truncateLabel(label: string): string { + const maxChars = Math.floor(TEXT_MAX_WIDTH / CHAR_WIDTH_APPROX); + return label.length > maxChars ? `${label.slice(0, maxChars - 1)}…` : label; +} + interface ThresholdLegendProps { thresholds: ThresholdOptions; format: FormatOptions | undefined; @@ -50,7 +60,7 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: [steps, paletteColors, defaultColor, format], ); - const boxWidth = 110; + const boxWidth = LEGEND_WIDTH; const boxHeight = rows.length * ROW_HEIGHT + PADDING * 2; return ( @@ -78,7 +88,8 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: fill={muiTheme.palette.text.primary} style={NO_SELECT_STYLE} > - {row.label} + {row.label} + {truncateLabel(row.label)} ); diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index b550a7dd0..5649ad7d0 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -11,16 +11,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box } from '@mui/material'; +import { Box, IconButton } from '@mui/material'; import type { FormatOptions, ThresholdOptions } from '@perses-dev/components'; import { FormatControls, + InfoTooltip, OptionsEditorColumn, OptionsEditorGrid, OptionsEditorGroup, ThresholdsEditor, } from '@perses-dev/components'; import type { OptionsEditorProps } from '@perses-dev/plugin-system'; +import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; import type { ReactElement } from 'react'; import { useCallback } from 'react'; @@ -64,7 +66,30 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr - + + ({ borderRadius: theme.shape.borderRadius, padding: '4px', margin: '0 2px' })} + > + theme.palette.grey[700] }} + /> + + + } + > diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx new file mode 100644 index 000000000..8d65b7349 --- /dev/null +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -0,0 +1,92 @@ +// Copyright The Perses Authors +// Licensed 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 { Box, IconButton, Tooltip as MuiTooltip, tooltipClasses, Typography } from '@mui/material'; +import type { TooltipProps as MuiTooltipProps } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; +import type { ReactElement } from 'react'; + +import type { MouseShortcutTooltip } from '../../utils/shortcuts'; +import { SHORTCUTS } from '../../utils/shortcuts'; + +const Tooltip = styled(({ className, ...props }: MuiTooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.background.tooltip, + color: theme.palette.text.primary, + maxWidth: '300px', + padding: theme.spacing(1), + boxShadow: theme.shadows[1], + }, + [`& .${tooltipClasses.arrow}`]: { + color: theme.palette.background.tooltip, + }, +})); + +const styles = { + key: { + px: 0.75, + py: 0.25, + borderRadius: 0.5, + border: 1, + borderColor: 'divider', + backgroundColor: 'action.hover', + fontFamily: 'monospace', + fontSize: '0.75rem', + whiteSpace: 'nowrap', + }, + row: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, py: 0.25 }, + keys: { display: 'flex', gap: 0.5 }, + title: { fontWeight: 'medium', mb: 0.5 }, + iconButton: { opacity: 0.7, padding: '2px' }, +} as const; + +const SHORTCUT_ROWS: MouseShortcutTooltip[] = SHORTCUTS.map((s) => s.tooltip); + +function ShortcutList({ rows }: { rows: MouseShortcutTooltip[] }): ReactElement { + return ( + <> + {rows.map(({ keys, action }) => ( + + {action} + + {keys.map((key) => ( + + {key} + + ))} + + + ))} + + ); +} + +const TOOLTIP_TITLE = ( + + Canvas shortcuts + + +); + +export function CanvasShortcutsTooltip(): ReactElement { + return ( + + + + + + ); +} diff --git a/canvas/src/hooks/useZoom.ts b/canvas/src/hooks/useZoom.ts index 5f863ad7b..bf7c8a7f4 100644 --- a/canvas/src/hooks/useZoom.ts +++ b/canvas/src/hooks/useZoom.ts @@ -18,6 +18,7 @@ import type { PointerEvent } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; import type { Point } from '../model'; +import { resolveMouseShortcut } from '../utils/shortcuts'; const FIT_PADDING = 40; @@ -44,19 +45,21 @@ export function useZoom(): UseZoomResult { const svgRef = useCallback( (node: SVGSVGElement | null): void => { if (!node) { + nodeRef.current = null; return; } nodeRef.current = node; zoomBehavior.filter((event: Event) => { - if (event.type === 'dblclick') { + if (event instanceof MouseEvent) { + const shortcut = resolveMouseShortcut(event); + return shortcut?.id === 'zoom' || shortcut?.id === 'pan'; + } else { return false; } - if (event instanceof WheelEvent) { - return event.ctrlKey || event.metaKey; - } - return event instanceof MouseEvent && event.button === 1; }); - zoomBehavior.on('start', () => setIsPanning(true)); + zoomBehavior.on('start', ({ sourceEvent }: { sourceEvent: Event }) => { + setIsPanning(sourceEvent instanceof MouseEvent && resolveMouseShortcut(sourceEvent)?.id === 'pan'); + }); zoomBehavior.on('zoom', ({ transform: t }: { transform: ZoomTransform }) => { setTransform(t); }); diff --git a/canvas/src/utils/shortcuts.test.ts b/canvas/src/utils/shortcuts.test.ts new file mode 100644 index 000000000..2cf7e873f --- /dev/null +++ b/canvas/src/utils/shortcuts.test.ts @@ -0,0 +1,59 @@ +// Copyright The Perses Authors +// Licensed 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 { describe, expect, it } from 'vitest'; + +import { resolveMouseShortcut } from './shortcuts'; + +describe('resolveMouseShortcut', () => { + it('returns zoom on wheel + ctrlKey', () => { + expect(resolveMouseShortcut({ type: 'wheel', ctrlKey: true, metaKey: false })?.id).toBe('zoom'); + }); + + it('returns zoom on wheel + metaKey (pinch-to-zoom)', () => { + expect(resolveMouseShortcut({ type: 'wheel', ctrlKey: false, metaKey: true })?.id).toBe('zoom'); + }); + + it('returns null on wheel without modifier', () => { + expect(resolveMouseShortcut({ type: 'wheel', ctrlKey: false, metaKey: false })).toBeNull(); + }); + + it('returns pan on middle-click regardless of modifier', () => { + expect(resolveMouseShortcut({ type: 'mousedown', ctrlKey: false, metaKey: false, button: 1 })?.id).toBe('pan'); + expect(resolveMouseShortcut({ type: 'mousedown', ctrlKey: true, metaKey: false, button: 1 })?.id).toBe('pan'); + }); + + it('returns null on left-click', () => { + expect(resolveMouseShortcut({ type: 'mousedown', ctrlKey: false, metaKey: false, button: 0 })).toBeNull(); + }); + + it('returns null on right-click', () => { + expect(resolveMouseShortcut({ type: 'mousedown', ctrlKey: false, metaKey: false, button: 2 })).toBeNull(); + }); + + it('returns resetView on dblclick (no modifier)', () => { + expect(resolveMouseShortcut({ type: 'dblclick', ctrlKey: false, metaKey: false })?.id).toBe('resetView'); + }); + + it('returns fitView on dblclick + ctrlKey', () => { + expect(resolveMouseShortcut({ type: 'dblclick', ctrlKey: true, metaKey: false })?.id).toBe('fitView'); + }); + + it('returns fitView on dblclick + metaKey', () => { + expect(resolveMouseShortcut({ type: 'dblclick', ctrlKey: false, metaKey: true })?.id).toBe('fitView'); + }); + + it('returns null for unrecognized event type', () => { + expect(resolveMouseShortcut({ type: 'mousemove', ctrlKey: false, metaKey: false })).toBeNull(); + }); +}); diff --git a/canvas/src/utils/shortcuts.ts b/canvas/src/utils/shortcuts.ts new file mode 100644 index 000000000..1ef977d70 --- /dev/null +++ b/canvas/src/utils/shortcuts.ts @@ -0,0 +1,87 @@ +// Copyright The Perses Authors +// Licensed 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. + +// Single source of truth for all canvas interaction triggers. +// keys/action fields drive the shortcuts dialog. Adding or changing a shortcut means +// editing only this file. + +function detectMac(): boolean { + if (typeof navigator === 'undefined') return false; + const p = (navigator.platform ?? navigator.userAgent).toLowerCase(); + return p.includes('mac'); +} + +export const MOD_KEY = detectMac() ? '⌘' : 'Ctrl'; + +export interface MouseShortcutTooltip { + keys: readonly string[]; + action: string; +} + +export interface MouseShortcutDefinition { + mouseEvent: string; + modifierKey?: boolean; + mouseButton?: number; +} + +export type MouseShortcutId = 'zoom' | 'pan' | 'resetView' | 'fitView'; + +export interface MouseShortcut { + id: MouseShortcutId; + tooltip: MouseShortcutTooltip; + definition: MouseShortcutDefinition; +} + +export const SHORTCUTS: MouseShortcut[] = [ + { + id: 'zoom', + tooltip: { keys: [MOD_KEY, 'Scroll'], action: 'Zoom in / out' }, + definition: { mouseEvent: 'wheel', modifierKey: true }, + }, + { + id: 'pan', + tooltip: { keys: ['Middle mouse', 'Drag'], action: 'Pan' }, + definition: { mouseEvent: 'mousedown', mouseButton: 1 }, + }, + { + id: 'resetView', + tooltip: { keys: ['Double-click'], action: 'Reset pan & zoom' }, + definition: { mouseEvent: 'dblclick', modifierKey: false }, + }, + { + id: 'fitView', + tooltip: { keys: [MOD_KEY, 'Double-click'], action: 'Fit all nodes in view' }, + definition: { mouseEvent: 'dblclick', modifierKey: true }, + }, +]; + +type ResolveEvent = { + type: string; + ctrlKey: boolean; + metaKey: boolean; + button?: number; +}; + +export function resolveMouseShortcut(event: ResolveEvent): MouseShortcut | null { + const modifierKey = event.ctrlKey || event.metaKey; + return ( + SHORTCUTS.find((s) => { + const definition = s.definition; + return ( + definition.mouseEvent === event.type && + (definition.modifierKey === undefined || definition.modifierKey === modifierKey) && + (definition.mouseButton === undefined || definition.mouseButton === event.button) + ); + }) ?? null + ); +}