From 499d8aec99d69bc428b6eaa42c34f9de7f72ad8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 5 Oct 2026 14:19:58 +0200 Subject: [PATCH 01/12] [ENHANCEMENT] refactor mouse shortcuts for canvas interactions and add shortcuts tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/editor/EditorCanvas.tsx | 17 ++-- canvas/src/components/panel/CanvasPanel.tsx | 94 ++++++++++++------- .../shared/CanvasShortcutsTooltip.tsx | 74 +++++++++++++++ canvas/src/hooks/useZoom.ts | 15 +-- canvas/src/utils/shortcuts.test.ts | 59 ++++++++++++ canvas/src/utils/shortcuts.ts | 87 +++++++++++++++++ 6 files changed, 298 insertions(+), 48 deletions(-) create mode 100644 canvas/src/components/shared/CanvasShortcutsTooltip.tsx create mode 100644 canvas/src/utils/shortcuts.test.ts create mode 100644 canvas/src/utils/shortcuts.ts 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..9ab28172c 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,21 @@ 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'; const SVG_STYLE_BASE = { display: 'block' } as const; const EMPTY_THRESHOLDS: ThresholdOptions = {}; +const PANEL_WRAPPER_SX = { position: 'relative' as const }; + +const LEGEND_WIDTH = 110; +const LEGEND_MARGIN = 8; +const BTN_MARGIN = 4; +const BTN_HEIGHT = 32; interface PanelSvgProps { svgRef: (node: SVGSVGElement | null) => void; @@ -48,13 +57,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 +74,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/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx new file mode 100644 index 000000000..7bdbec9ba --- /dev/null +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -0,0 +1,74 @@ +// 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, Typography } from '@mui/material'; +import { StyledTooltip as Tooltip } from '@perses-dev/components'; +import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; +import type { ReactElement } from 'react'; + +import type { MouseShortcutTooltip } from '../../utils/shortcuts'; +import { SHORTCUTS } from '../../utils/shortcuts'; + +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 }, + icon: { cursor: 'default', opacity: 0.7 }, +} 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 + ); +} From 11cb8410d913b5d648a75761a3c85b8b405a23e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 5 Oct 2026 14:28:44 +0200 Subject: [PATCH 02/12] [ENHANCEMENT] implement label truncation in ThresholdLegend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/panel/CanvasPanel.tsx | 3 +-- canvas/src/components/panel/ThresholdLegend.tsx | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 9ab28172c..a1f22b86f 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -27,13 +27,12 @@ import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLaye 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_WIDTH = 110; const LEGEND_MARGIN = 8; const BTN_MARGIN = 4; const BTN_HEIGHT = 32; diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index 5a675e2ed..f5bd31220 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,7 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: fill={muiTheme.palette.text.primary} style={NO_SELECT_STYLE} > - {row.label} + {truncateLabel(row.label)} ); From e37c82d8e37e2d7daa4a92967768c86f572bc5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 5 Oct 2026 15:03:09 +0200 Subject: [PATCH 03/12] [ENHANCEMENT] refactor tooltip component with styled MUI Tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../shared/CanvasShortcutsTooltip.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx index 7bdbec9ba..90c079051 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -11,14 +11,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, Typography } from '@mui/material'; -import { StyledTooltip as Tooltip } from '@perses-dev/components'; +import { Box, 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, From 446d551b6afbe0335316e4e24b98e14c3f71ff63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 5 Oct 2026 15:07:00 +0200 Subject: [PATCH 04/12] Add info tooltip for edge thickness settings in GlobalSettingsEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../settings/GlobalSettingsEditor.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index b550a7dd0..b023f3259 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -11,10 +11,11 @@ // 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, @@ -23,6 +24,7 @@ import { import type { OptionsEditorProps } from '@perses-dev/plugin-system'; import type { ReactElement } from 'react'; import { useCallback } from 'react'; +import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; import { EditorStateProvider } from '../../contexts/EditorContext'; import { SpecProvider } from '../../contexts/SpecContext'; @@ -64,7 +66,28 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr - + + ({ borderRadius: theme.shape.borderRadius, padding: '4px', margin: '0 2px' })} + > + theme.palette.grey[700] }} + /> + + + } + > From c164b8e849b7169274cecfd2043688de7919816d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 5 Oct 2026 15:22:54 +0200 Subject: [PATCH 05/12] fix format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/settings/GlobalSettingsEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index b023f3259..cbc953397 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -22,9 +22,9 @@ import { 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'; -import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; import { EditorStateProvider } from '../../contexts/EditorContext'; import { SpecProvider } from '../../contexts/SpecContext'; From 5cfb3357255603d3aca0920dd52455a7876fe2e5 Mon Sep 17 00:00:00 2001 From: Adrian <56292595+adrianSepiol@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:58:32 +0200 Subject: [PATCH 06/12] Potential fix for pull request finding 'Expose full threshold values for truncated legend labels' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Adrian <56292595+adrianSepiol@users.noreply.github.com> --- canvas/src/components/panel/ThresholdLegend.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index f5bd31220..2b2b1ec32 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -88,7 +88,8 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: fill={muiTheme.palette.text.primary} style={NO_SELECT_STYLE} > - {truncateLabel(row.label)} +{row.label} +{truncateLabel(row.label)} ); From ee8f4766ada67c322e9e96fccd954f09f3625480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 6 Oct 2026 15:05:32 +0200 Subject: [PATCH 07/12] [ENHANCEMENT] improve tooltip accessibility and styling in CanvasShortcutsTooltip and GlobalSettingsEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/panel/ThresholdLegend.tsx | 4 ++-- canvas/src/components/settings/GlobalSettingsEditor.tsx | 6 ++++-- canvas/src/components/shared/CanvasShortcutsTooltip.tsx | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index 2b2b1ec32..b2c5d2bfa 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -88,8 +88,8 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: fill={muiTheme.palette.text.primary} style={NO_SELECT_STYLE} > -{row.label} -{truncateLabel(row.label)} + {row.label} + {truncateLabel(row.label)} ); diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index cbc953397..5649ad7d0 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -70,17 +70,19 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr title="Edge thickness" icon={ ({ 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 index 90c079051..8d65b7349 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, Tooltip as MuiTooltip, tooltipClasses, Typography } from '@mui/material'; +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'; @@ -50,7 +50,7 @@ const styles = { 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 }, - icon: { cursor: 'default', opacity: 0.7 }, + iconButton: { opacity: 0.7, padding: '2px' }, } as const; const SHORTCUT_ROWS: MouseShortcutTooltip[] = SHORTCUTS.map((s) => s.tooltip); @@ -84,7 +84,9 @@ const TOOLTIP_TITLE = ( export function CanvasShortcutsTooltip(): ReactElement { return ( - + + + ); } From 80761ab69b4fb399557790fc65e49f251f98d94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 8 Oct 2026 08:51:46 +0200 Subject: [PATCH 08/12] Update tooltip label for canvas shortcuts and clean up comments in shortcuts file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/shared/CanvasShortcutsTooltip.tsx | 2 +- canvas/src/utils/shortcuts.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx index 8d65b7349..8cae9df53 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -84,7 +84,7 @@ const TOOLTIP_TITLE = ( export function CanvasShortcutsTooltip(): ReactElement { return ( - + diff --git a/canvas/src/utils/shortcuts.ts b/canvas/src/utils/shortcuts.ts index 1ef977d70..9c58d4fb2 100644 --- a/canvas/src/utils/shortcuts.ts +++ b/canvas/src/utils/shortcuts.ts @@ -11,10 +11,6 @@ // 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(); From dc6317572fede47effccdac2fb726e4416330ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 8 Oct 2026 16:39:45 +0200 Subject: [PATCH 09/12] Move tooltip to actions and add it to editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/Canvas.tsx | 4 +- .../components/editor/EditorItemsPanel.tsx | 2 + canvas/src/components/panel/CanvasPanel.tsx | 72 ++++++++----------- .../src/components/panel/ThresholdLegend.tsx | 21 +++--- .../settings/GlobalSettingsEditor.tsx | 39 +++++----- .../shared/CanvasShortcutsTooltip.tsx | 15 ++-- canvas/src/hooks/useZoom.ts | 2 +- canvas/src/utils/shortcuts.test.ts | 4 +- canvas/src/utils/shortcuts.ts | 7 +- 9 files changed, 79 insertions(+), 87 deletions(-) diff --git a/canvas/src/Canvas.tsx b/canvas/src/Canvas.tsx index cdccc8ad7..88c8dae9f 100644 --- a/canvas/src/Canvas.tsx +++ b/canvas/src/Canvas.tsx @@ -15,11 +15,13 @@ import type { PanelPlugin } from '@perses-dev/plugin-system'; import { CanvasPanel } from './components/panel/CanvasPanel'; import { GlobalSettingsEditor } from './components/settings/GlobalSettingsEditor'; -import type { CanvasSpec, CanvasProps } from './model'; +import { CanvasShortcutsTooltip } from './components/shared/CanvasShortcutsTooltip'; +import type { CanvasProps, CanvasSpec } from './model'; export const Canvas: PanelPlugin = { PanelComponent: CanvasPanel, panelOptionsEditorComponents: [{ label: 'Settings', content: GlobalSettingsEditor }], supportedQueryTypes: ['TimeSeriesQuery'], createInitialOptions: () => ({}), + actions: [{ component: CanvasShortcutsTooltip, location: 'header' }], }; diff --git a/canvas/src/components/editor/EditorItemsPanel.tsx b/canvas/src/components/editor/EditorItemsPanel.tsx index 774f35edf..2fd18ee5f 100644 --- a/canvas/src/components/editor/EditorItemsPanel.tsx +++ b/canvas/src/components/editor/EditorItemsPanel.tsx @@ -20,6 +20,7 @@ import { useEditorContext } from '../../contexts/EditorContext'; import { useSpecContext } from '../../contexts/SpecContext'; import { ZoomProvider } from '../../contexts/ZoomContext'; import { useZoom } from '../../hooks/useZoom'; +import { CanvasShortcutsTooltip } from '../shared/CanvasShortcutsTooltip'; import { BackgroundPropertiesPanel } from './BackgroundPropertiesPanel'; import { EdgePropertiesPanel } from './EdgePropertiesPanel'; import { EditorCanvas } from './EditorCanvas'; @@ -171,6 +172,7 @@ export function EditorItemsPanel(): ReactElement { > Delete + diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index a1f22b86f..52fdc48e2 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -11,7 +11,6 @@ // 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'; @@ -24,18 +23,13 @@ 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 { 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; @@ -76,49 +70,39 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg const legendX = legendPosition === 'right' ? width - LEGEND_WIDTH - LEGEND_MARGIN : LEGEND_MARGIN; const legendY = legendPosition === 'right' - ? BTN_MARGIN + BTN_HEIGHT + BTN_MARGIN + ? LEGEND_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 b2c5d2bfa..564ea8129 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -27,7 +27,7 @@ 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; - +const LEGEND_FONT_FAMILY = 'sans-serif'; // Approximate character width for font-size 11px sans-serif. const CHAR_WIDTH_APPROX = 6.5; @@ -50,12 +50,16 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: const rows = useMemo( () => [ - ...steps.map((step, i) => ({ - color: step.color ?? paletteColors[i] ?? defaultColor, - label: `≥ ${formatValue(step.value, format)}`, - key: String(step.value), - })), - { color: defaultColor, label: 'default', key: 'default' }, + ...steps.map((step, i) => { + const label = `≥ ${formatValue(step.value, format)}`; + return { + color: step.color ?? paletteColors[i] ?? defaultColor, + label, + displayLabel: truncateLabel(label), + key: String(step.value), + }; + }), + { color: defaultColor, label: 'default', displayLabel: truncateLabel('default'), key: 'default' }, ], [steps, paletteColors, defaultColor, format], ); @@ -85,11 +89,12 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: x={position.x + PADDING + LABEL_OFFSET} y={ry + SWATCH_SIZE - 2} fontSize={FONT_SIZE} + fontFamily={LEGEND_FONT_FAMILY} fill={muiTheme.palette.text.primary} style={NO_SELECT_STYLE} > {row.label} - {truncateLabel(row.label)} + {row.displayLabel} ); diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index 5649ad7d0..f1501f77f 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -35,6 +35,23 @@ import { LegendSettings } from './LegendSettings'; const DEFAULT_FORMAT: FormatOptions = { unit: 'decimal' }; const EDITOR_COLUMN_SX = { display: 'flex', flexDirection: 'column' as const, gap: 3 }; +const EDGE_THICKNESS_INFO_ICON = ( + + + + + +); type GlobalSettingsEditorProps = OptionsEditorProps; @@ -68,27 +85,7 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr - ({ borderRadius: theme.shape.borderRadius, padding: '4px', margin: '0 2px' })} - > - theme.palette.grey[700] }} - /> - - - } + icon={EDGE_THICKNESS_INFO_ICON} > diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx index 8cae9df53..c10a01fc8 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -11,18 +11,20 @@ // 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 { Box, IconButton, Tooltip as MuiTooltip, tooltipClasses, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; import type { ReactElement } from 'react'; +import { useMemo } from 'react'; import type { MouseShortcutTooltip } from '../../utils/shortcuts'; import { SHORTCUTS } from '../../utils/shortcuts'; -const Tooltip = styled(({ className, ...props }: MuiTooltipProps) => ( - -))(({ theme }) => ({ +const Tooltip = styled(({ className, ...props }: MuiTooltipProps) => { + const classes = useMemo(() => ({ popper: className }), [className]); + return ; +})(({ theme }) => ({ [`& .${tooltipClasses.tooltip}`]: { backgroundColor: theme.palette.background.tooltip, color: theme.palette.text.primary, @@ -50,7 +52,6 @@ const styles = { 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); @@ -63,7 +64,7 @@ function ShortcutList({ rows }: { rows: MouseShortcutTooltip[] }): ReactElement {action} {keys.map((key) => ( - + {key} ))} @@ -84,7 +85,7 @@ const TOOLTIP_TITLE = ( export function CanvasShortcutsTooltip(): ReactElement { return ( - + diff --git a/canvas/src/hooks/useZoom.ts b/canvas/src/hooks/useZoom.ts index bf7c8a7f4..ef3e9c7dd 100644 --- a/canvas/src/hooks/useZoom.ts +++ b/canvas/src/hooks/useZoom.ts @@ -57,7 +57,7 @@ export function useZoom(): UseZoomResult { return false; } }); - zoomBehavior.on('start', ({ sourceEvent }: { sourceEvent: Event }) => { + zoomBehavior.on('start', ({ sourceEvent }: { sourceEvent: Event | null }) => { setIsPanning(sourceEvent instanceof MouseEvent && resolveMouseShortcut(sourceEvent)?.id === 'pan'); }); zoomBehavior.on('zoom', ({ transform: t }: { transform: ZoomTransform }) => { diff --git a/canvas/src/utils/shortcuts.test.ts b/canvas/src/utils/shortcuts.test.ts index 2cf7e873f..2971a1cfe 100644 --- a/canvas/src/utils/shortcuts.test.ts +++ b/canvas/src/utils/shortcuts.test.ts @@ -16,11 +16,11 @@ import { describe, expect, it } from 'vitest'; import { resolveMouseShortcut } from './shortcuts'; describe('resolveMouseShortcut', () => { - it('returns zoom on wheel + ctrlKey', () => { + it('returns zoom on wheel + ctrlKey (trackpad pinch-to-zoom)', () => { expect(resolveMouseShortcut({ type: 'wheel', ctrlKey: true, metaKey: false })?.id).toBe('zoom'); }); - it('returns zoom on wheel + metaKey (pinch-to-zoom)', () => { + it('returns zoom on wheel + metaKey (Cmd+scroll on Mac)', () => { expect(resolveMouseShortcut({ type: 'wheel', ctrlKey: false, metaKey: true })?.id).toBe('zoom'); }); diff --git a/canvas/src/utils/shortcuts.ts b/canvas/src/utils/shortcuts.ts index 9c58d4fb2..8498cfb89 100644 --- a/canvas/src/utils/shortcuts.ts +++ b/canvas/src/utils/shortcuts.ts @@ -13,7 +13,8 @@ function detectMac(): boolean { if (typeof navigator === 'undefined') return false; - const p = (navigator.platform ?? navigator.userAgent).toLowerCase(); + const p = ( navigator.platform || navigator.userAgent + ).toLowerCase(); return p.includes('mac'); } @@ -25,7 +26,7 @@ export interface MouseShortcutTooltip { } export interface MouseShortcutDefinition { - mouseEvent: string; + mouseEvent: 'wheel' | 'mousedown' | 'dblclick'; modifierKey?: boolean; mouseButton?: number; } @@ -38,7 +39,7 @@ export interface MouseShortcut { definition: MouseShortcutDefinition; } -export const SHORTCUTS: MouseShortcut[] = [ +export const SHORTCUTS: readonly MouseShortcut[] = [ { id: 'zoom', tooltip: { keys: [MOD_KEY, 'Scroll'], action: 'Zoom in / out' }, From dec5b2bb755af704a89425ba0ac263530dce1735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 8 Oct 2026 17:18:29 +0200 Subject: [PATCH 10/12] [ENHANCEMENT] Improve canvas shortcuts with typed keyboard shortcuts, split view/editor tooltips, and optimized hot-path resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/Canvas.tsx | 4 +- canvas/src/components/editor/EditorCanvas.tsx | 7 +-- .../components/editor/EditorItemsPanel.tsx | 4 +- canvas/src/components/panel/CanvasPanel.tsx | 4 +- .../settings/GlobalSettingsEditor.tsx | 5 +- .../shared/CanvasShortcutsTooltip.tsx | 54 +++++++++++++------ canvas/src/utils/shortcuts.test.ts | 16 +++++- canvas/src/utils/shortcuts.ts | 48 ++++++++++++++--- 8 files changed, 101 insertions(+), 41 deletions(-) diff --git a/canvas/src/Canvas.tsx b/canvas/src/Canvas.tsx index 88c8dae9f..9ad72f915 100644 --- a/canvas/src/Canvas.tsx +++ b/canvas/src/Canvas.tsx @@ -15,7 +15,7 @@ import type { PanelPlugin } from '@perses-dev/plugin-system'; import { CanvasPanel } from './components/panel/CanvasPanel'; import { GlobalSettingsEditor } from './components/settings/GlobalSettingsEditor'; -import { CanvasShortcutsTooltip } from './components/shared/CanvasShortcutsTooltip'; +import { ViewCanvasShortcutsTooltip } from './components/shared/CanvasShortcutsTooltip'; import type { CanvasProps, CanvasSpec } from './model'; export const Canvas: PanelPlugin = { @@ -23,5 +23,5 @@ export const Canvas: PanelPlugin = { panelOptionsEditorComponents: [{ label: 'Settings', content: GlobalSettingsEditor }], supportedQueryTypes: ['TimeSeriesQuery'], createInitialOptions: () => ({}), - actions: [{ component: CanvasShortcutsTooltip, location: 'header' }], + actions: [{ component: ViewCanvasShortcutsTooltip, location: 'header' }], }; diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index c816ab5a6..12708baed 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -26,7 +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 { resolveMouseShortcut, resolveKeyboardShortcut } from '../../utils/shortcuts'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { DragEdgeLine } from './DragEdgeLine'; import { EditorEdgeItem } from './EditorEdgeItem'; @@ -186,10 +186,7 @@ export function EditorCanvas({ const onKeyDown = useCallback( (event: KeyboardEvent): void => { - if (event.key !== 'Delete' && event.key !== 'Backspace') { - return; - } - if (selectedIds.size > 0) { + if (resolveKeyboardShortcut(event.key)?.id === 'deleteSelected' && selectedIds.size > 0) { deleteSelected(); } }, diff --git a/canvas/src/components/editor/EditorItemsPanel.tsx b/canvas/src/components/editor/EditorItemsPanel.tsx index 2fd18ee5f..6882541bb 100644 --- a/canvas/src/components/editor/EditorItemsPanel.tsx +++ b/canvas/src/components/editor/EditorItemsPanel.tsx @@ -20,7 +20,7 @@ import { useEditorContext } from '../../contexts/EditorContext'; import { useSpecContext } from '../../contexts/SpecContext'; import { ZoomProvider } from '../../contexts/ZoomContext'; import { useZoom } from '../../hooks/useZoom'; -import { CanvasShortcutsTooltip } from '../shared/CanvasShortcutsTooltip'; +import { EditorCanvasShortcutsTooltip } from '../shared/CanvasShortcutsTooltip'; import { BackgroundPropertiesPanel } from './BackgroundPropertiesPanel'; import { EdgePropertiesPanel } from './EdgePropertiesPanel'; import { EditorCanvas } from './EditorCanvas'; @@ -172,7 +172,7 @@ export function EditorItemsPanel(): ReactElement { > Delete - + diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 52fdc48e2..5007439ac 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -69,9 +69,7 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg const legendPosition = spec.legend?.position ?? 'bottom'; 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' ? LEGEND_MARGIN : height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24; const svgStyle = useMemo(() => ({ ...SVG_STYLE_BASE, cursor: isPanning ? 'grabbing' : 'grab' }), [isPanning]); diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index f1501f77f..61ea452ae 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -83,10 +83,7 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr - + diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx index c10a01fc8..58990b83a 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -16,10 +16,10 @@ import { Box, IconButton, Tooltip as MuiTooltip, tooltipClasses, Typography } fr import { styled } from '@mui/material/styles'; import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; import type { ReactElement } from 'react'; -import { useMemo } from 'react'; +import { forwardRef, useMemo } from 'react'; -import type { MouseShortcutTooltip } from '../../utils/shortcuts'; -import { SHORTCUTS } from '../../utils/shortcuts'; +import type { ShortcutTooltip } from '../../utils/shortcuts'; +import { EDITOR_SHORTCUTS, VIEW_SHORTCUTS } from '../../utils/shortcuts'; const Tooltip = styled(({ className, ...props }: MuiTooltipProps) => { const classes = useMemo(() => ({ popper: className }), [className]); @@ -54,9 +54,7 @@ const styles = { title: { fontWeight: 'medium', mb: 0.5 }, } as const; -const SHORTCUT_ROWS: MouseShortcutTooltip[] = SHORTCUTS.map((s) => s.tooltip); - -function ShortcutList({ rows }: { rows: MouseShortcutTooltip[] }): ReactElement { +function ShortcutList({ rows }: { rows: readonly ShortcutTooltip[] }): ReactElement { return ( <> {rows.map(({ keys, action }) => ( @@ -75,19 +73,41 @@ function ShortcutList({ rows }: { rows: MouseShortcutTooltip[] }): ReactElement ); } -const TOOLTIP_TITLE = ( - - Canvas shortcuts - - -); +const VIEW_ROWS: ShortcutTooltip[] = VIEW_SHORTCUTS.map((s) => s.tooltip); +const EDITOR_ROWS: ShortcutTooltip[] = EDITOR_SHORTCUTS.map((s) => s.tooltip); -export function CanvasShortcutsTooltip(): ReactElement { +function TooltipTitle({ rows }: { rows: readonly ShortcutTooltip[] }): ReactElement { return ( - - - - + + Canvas shortcuts + + + ); +} + +const VIEW_TOOLTIP_TITLE = ; +const EDITOR_TOOLTIP_TITLE = ; + +const ShortcutsButton = forwardRef(function ShortcutsButton(props, ref) { + return ( + + + + ); +}); + +function ShortcutsTooltip({ title }: { title: ReactElement }): ReactElement { + return ( + + ); } + +export function ViewCanvasShortcutsTooltip(): ReactElement { + return ; +} + +export function EditorCanvasShortcutsTooltip(): ReactElement { + return ; +} diff --git a/canvas/src/utils/shortcuts.test.ts b/canvas/src/utils/shortcuts.test.ts index 2971a1cfe..5c2f5d42d 100644 --- a/canvas/src/utils/shortcuts.test.ts +++ b/canvas/src/utils/shortcuts.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest'; -import { resolveMouseShortcut } from './shortcuts'; +import { resolveKeyboardShortcut, resolveMouseShortcut } from './shortcuts'; describe('resolveMouseShortcut', () => { it('returns zoom on wheel + ctrlKey (trackpad pinch-to-zoom)', () => { @@ -57,3 +57,17 @@ describe('resolveMouseShortcut', () => { expect(resolveMouseShortcut({ type: 'mousemove', ctrlKey: false, metaKey: false })).toBeNull(); }); }); + +describe('resolveKeyboardShortcut', () => { + it('returns deleteSelected on Delete', () => { + expect(resolveKeyboardShortcut('Delete')?.id).toBe('deleteSelected'); + }); + + it('returns deleteSelected on Backspace', () => { + expect(resolveKeyboardShortcut('Backspace')?.id).toBe('deleteSelected'); + }); + + it('returns null for unrecognized key', () => { + expect(resolveKeyboardShortcut('Escape')).toBeNull(); + }); +}); diff --git a/canvas/src/utils/shortcuts.ts b/canvas/src/utils/shortcuts.ts index 8498cfb89..3d2ac611b 100644 --- a/canvas/src/utils/shortcuts.ts +++ b/canvas/src/utils/shortcuts.ts @@ -13,14 +13,13 @@ function detectMac(): boolean { if (typeof navigator === 'undefined') return false; - const p = ( navigator.platform || navigator.userAgent - ).toLowerCase(); + const p = (navigator.platform || navigator.userAgent).toLowerCase(); return p.includes('mac'); } export const MOD_KEY = detectMac() ? '⌘' : 'Ctrl'; -export interface MouseShortcutTooltip { +export interface ShortcutTooltip { keys: readonly string[]; action: string; } @@ -31,15 +30,29 @@ export interface MouseShortcutDefinition { mouseButton?: number; } -export type MouseShortcutId = 'zoom' | 'pan' | 'resetView' | 'fitView'; +const MOUSE_SHORTCUT_IDS = ['zoom', 'pan', 'resetView', 'fitView'] as const; +const KEYBOARD_SHORTCUT_IDS = ['deleteSelected'] as const; + +export type MouseShortcutId = (typeof MOUSE_SHORTCUT_IDS)[number]; +export type KeyboardShortcutId = (typeof KEYBOARD_SHORTCUT_IDS)[number]; export interface MouseShortcut { id: MouseShortcutId; - tooltip: MouseShortcutTooltip; + tooltip: ShortcutTooltip; definition: MouseShortcutDefinition; } -export const SHORTCUTS: readonly MouseShortcut[] = [ +export interface KeyboardShortcutDefinition { + keys: readonly string[]; +} + +export interface KeyboardShortcut { + id: KeyboardShortcutId; + tooltip: ShortcutTooltip; + definition: KeyboardShortcutDefinition; +} + +export const VIEW_SHORTCUTS: ReadonlyArray = [ { id: 'zoom', tooltip: { keys: [MOD_KEY, 'Scroll'], action: 'Zoom in / out' }, @@ -62,6 +75,23 @@ export const SHORTCUTS: readonly MouseShortcut[] = [ }, ]; +export const EDITOR_SHORTCUTS: ReadonlyArray = [ + ...VIEW_SHORTCUTS, + { + id: 'deleteSelected', + tooltip: { keys: ['Delete', 'Backspace'], action: 'Delete selected' }, + definition: { keys: ['Delete', 'Backspace'] }, + }, +]; + +const MOUSE_SHORTCUTS: readonly MouseShortcut[] = VIEW_SHORTCUTS.filter( + (s): s is MouseShortcut => MOUSE_SHORTCUT_IDS.includes(s.id as MouseShortcutId), +); + +const KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = EDITOR_SHORTCUTS.filter( + (s): s is KeyboardShortcut => KEYBOARD_SHORTCUT_IDS.includes(s.id as KeyboardShortcutId), +); + type ResolveEvent = { type: string; ctrlKey: boolean; @@ -72,7 +102,7 @@ type ResolveEvent = { export function resolveMouseShortcut(event: ResolveEvent): MouseShortcut | null { const modifierKey = event.ctrlKey || event.metaKey; return ( - SHORTCUTS.find((s) => { + MOUSE_SHORTCUTS.find((s) => { const definition = s.definition; return ( definition.mouseEvent === event.type && @@ -82,3 +112,7 @@ export function resolveMouseShortcut(event: ResolveEvent): MouseShortcut | null }) ?? null ); } + +export function resolveKeyboardShortcut(key: string): KeyboardShortcut | null { + return KEYBOARD_SHORTCUTS.find((s) => s.definition.keys.includes(key)) ?? null; +} From acb6252de519a9faa53563baaaaa8a2e89f6ce7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 8 Oct 2026 17:37:33 +0200 Subject: [PATCH 11/12] add test for shortcut tooltip component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../shared/CanvasShortcutsTooltip.test.tsx | 77 +++++++++++++++++++ .../shared/CanvasShortcutsTooltip.tsx | 2 +- canvas/src/utils/shortcuts.ts | 8 +- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx new file mode 100644 index 000000000..5d81d9969 --- /dev/null +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx @@ -0,0 +1,77 @@ +// 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { EditorCanvasShortcutsTooltip, ViewCanvasShortcutsTooltip } from './CanvasShortcutsTooltip'; + +// jsdom has no navigator.platform → MOD_KEY falls back to 'Ctrl' +const MOD = 'Ctrl'; + +describe('ViewCanvasShortcutsTooltip', () => { + it('renders a button with the accessible label', () => { + render(); + expect(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })).toBeInTheDocument(); + }); + + it('shows view shortcut rows on hover', async () => { + render(); + await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); + await waitFor(() => { + expect(screen.getByText('Zoom in / out')).toBeInTheDocument(); + expect(screen.getByText('Pan')).toBeInTheDocument(); + expect(screen.getByText('Reset pan & zoom')).toBeInTheDocument(); + expect(screen.getByText('Fit all nodes in view')).toBeInTheDocument(); + }); + }); + + it('does not show the editor-only Delete shortcut', async () => { + render(); + await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); + await waitFor(() => screen.getByText('Zoom in / out')); + expect(screen.queryByText('Delete selected')).not.toBeInTheDocument(); + }); + + it('shows Ctrl as modifier key in jsdom (no navigator.platform)', async () => { + render(); + await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); + await waitFor(() => screen.getByText('Zoom in / out')); + expect(screen.getAllByText(MOD).length).toBeGreaterThan(0); + }); +}); + +describe('EditorCanvasShortcutsTooltip', () => { + it('renders a button with the accessible label', () => { + render(); + expect(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })).toBeInTheDocument(); + }); + + it('shows all view rows plus Delete selected on hover', async () => { + render(); + await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); + await waitFor(() => { + expect(screen.getByText('Zoom in / out')).toBeInTheDocument(); + expect(screen.getByText('Delete selected')).toBeInTheDocument(); + }); + }); + + it('shows Delete and Backspace key chips for the delete shortcut', async () => { + render(); + await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); + await waitFor(() => screen.getByText('Delete selected')); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect(screen.getByText('Backspace')).toBeInTheDocument(); + }); +}); diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx index 58990b83a..a303cc71b 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.tsx @@ -90,7 +90,7 @@ const EDITOR_TOOLTIP_TITLE = ; const ShortcutsButton = forwardRef(function ShortcutsButton(props, ref) { return ( - + ); diff --git a/canvas/src/utils/shortcuts.ts b/canvas/src/utils/shortcuts.ts index 3d2ac611b..37a96e545 100644 --- a/canvas/src/utils/shortcuts.ts +++ b/canvas/src/utils/shortcuts.ts @@ -84,12 +84,12 @@ export const EDITOR_SHORTCUTS: ReadonlyArray = }, ]; -const MOUSE_SHORTCUTS: readonly MouseShortcut[] = VIEW_SHORTCUTS.filter( - (s): s is MouseShortcut => MOUSE_SHORTCUT_IDS.includes(s.id as MouseShortcutId), +const MOUSE_SHORTCUTS: readonly MouseShortcut[] = VIEW_SHORTCUTS.filter((s): s is MouseShortcut => + MOUSE_SHORTCUT_IDS.includes(s.id as MouseShortcutId), ); -const KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = EDITOR_SHORTCUTS.filter( - (s): s is KeyboardShortcut => KEYBOARD_SHORTCUT_IDS.includes(s.id as KeyboardShortcutId), +const KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = EDITOR_SHORTCUTS.filter((s): s is KeyboardShortcut => + KEYBOARD_SHORTCUT_IDS.includes(s.id as KeyboardShortcutId), ); type ResolveEvent = { From 681fb3254b2a75be8e036f4b03e8331298965920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 8 Oct 2026 17:44:49 +0200 Subject: [PATCH 12/12] fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../shared/CanvasShortcutsTooltip.test.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx b/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx index 5d81d9969..be2171ce1 100644 --- a/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx +++ b/canvas/src/components/shared/CanvasShortcutsTooltip.test.tsx @@ -23,17 +23,17 @@ const MOD = 'Ctrl'; describe('ViewCanvasShortcutsTooltip', () => { it('renders a button with the accessible label', () => { render(); - expect(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })).toBeDefined(); }); it('shows view shortcut rows on hover', async () => { render(); await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); await waitFor(() => { - expect(screen.getByText('Zoom in / out')).toBeInTheDocument(); - expect(screen.getByText('Pan')).toBeInTheDocument(); - expect(screen.getByText('Reset pan & zoom')).toBeInTheDocument(); - expect(screen.getByText('Fit all nodes in view')).toBeInTheDocument(); + expect(screen.getByText('Zoom in / out')).toBeDefined(); + expect(screen.getByText('Pan')).toBeDefined(); + expect(screen.getByText('Reset pan & zoom')).toBeDefined(); + expect(screen.getByText('Fit all nodes in view')).toBeDefined(); }); }); @@ -41,7 +41,7 @@ describe('ViewCanvasShortcutsTooltip', () => { render(); await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); await waitFor(() => screen.getByText('Zoom in / out')); - expect(screen.queryByText('Delete selected')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete selected')).toBeNull(); }); it('shows Ctrl as modifier key in jsdom (no navigator.platform)', async () => { @@ -55,15 +55,15 @@ describe('ViewCanvasShortcutsTooltip', () => { describe('EditorCanvasShortcutsTooltip', () => { it('renders a button with the accessible label', () => { render(); - expect(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })).toBeDefined(); }); it('shows all view rows plus Delete selected on hover', async () => { render(); await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); await waitFor(() => { - expect(screen.getByText('Zoom in / out')).toBeInTheDocument(); - expect(screen.getByText('Delete selected')).toBeInTheDocument(); + expect(screen.getByText('Zoom in / out')).toBeDefined(); + expect(screen.getByText('Delete selected')).toBeDefined(); }); }); @@ -71,7 +71,7 @@ describe('EditorCanvasShortcutsTooltip', () => { render(); await userEvent.hover(screen.getByRole('button', { name: 'Canvas interaction shortcuts' })); await waitFor(() => screen.getByText('Delete selected')); - expect(screen.getByText('Delete')).toBeInTheDocument(); - expect(screen.getByText('Backspace')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeDefined(); + expect(screen.getByText('Backspace')).toBeDefined(); }); });