import { clamp } from 'lodash'; import React, { useLayoutEffect } from 'react'; import uPlot from 'uplot'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; interface KeyboardPluginProps { config: UPlotConfigBuilder; // onkeypress, onkeyup, onkeydown (triggered by vizlayout handlers) } const PIXELS_PER_MS = 0.1 as const; const SHIFT_MULTIPLIER = 2 as const; const KNOWN_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Shift', ' ']); const initHook = (u: uPlot) => { let vizLayoutViz: HTMLElement | null = u.root.closest('[tabindex]'); let pressedKeys = new Set(); let dragStartX: number | null = null; let keysLastHandledAt: number | null = null; if (!vizLayoutViz) { return; } const moveCursor = (dx: number, dy: number) => { const { cursor } = u; if (cursor.left === undefined || cursor.top === undefined) { return; } const { width, height } = u.over.style; const [maxX, maxY] = [Math.floor(parseFloat(width)), Math.floor(parseFloat(height))]; u.setCursor({ left: clamp(cursor.left + dx, 0, maxX), top: clamp(cursor.top + dy, 0, maxY), }); }; const handlePressedKeys = (time: number) => { const nothingPressed = pressedKeys.size === 0; if (nothingPressed || !u) { keysLastHandledAt = null; return; } const dt = time - (keysLastHandledAt ?? time); const dx = dt * PIXELS_PER_MS; let horValue = 0; let vertValue = 0; if (pressedKeys.has('ArrowUp')) { vertValue -= dx; } if (pressedKeys.has('ArrowDown')) { vertValue += dx; } if (pressedKeys.has('ArrowLeft')) { horValue -= dx; } if (pressedKeys.has('ArrowRight')) { horValue += dx; } if (pressedKeys.has('Shift')) { horValue *= SHIFT_MULTIPLIER; vertValue *= SHIFT_MULTIPLIER; } moveCursor(horValue, vertValue); const { cursor } = u; if (pressedKeys.has(' ') && cursor) { const drawHeight = Number(u.over.style.height.slice(0, -2)); u.setSelect( { left: cursor.left! < dragStartX! ? cursor.left! : dragStartX!, top: 0, width: Math.abs(cursor.left! - (dragStartX ?? cursor.left!)), height: drawHeight, }, false ); } keysLastHandledAt = time; window.requestAnimationFrame(handlePressedKeys); }; vizLayoutViz.addEventListener('keydown', (e) => { if (e.key === 'Tab') { // Hide the cursor if the user tabs away u.setCursor({ left: -5, top: -5 }); return; } if (!KNOWN_KEYS.has(e.key)) { return; } e.preventDefault(); e.stopPropagation(); const newKey = !pressedKeys.has(e.key); if (newKey) { const initiateAnimationLoop = pressedKeys.size === 0; pressedKeys.add(e.key); dragStartX = e.key === ' ' && dragStartX === null ? u.cursor.left! : dragStartX; if (initiateAnimationLoop) { window.requestAnimationFrame(handlePressedKeys); } } }); vizLayoutViz.addEventListener('keyup', (e) => { if (!KNOWN_KEYS.has(e.key)) { return; } pressedKeys.delete(e.key); if (e.key === ' ') { e.preventDefault(); e.stopPropagation(); // We do this so setSelect hooks get fired, zooming the plot u.setSelect(u.select); dragStartX = null; } }); vizLayoutViz.addEventListener('focus', (e) => { // We only want to initialize the cursor if the user is using keyboard controls if (!vizLayoutViz?.matches(':focus-visible')) { return; } // Is there a more idiomatic way to do this? const drawWidth = parseFloat(u.over.style.width); const drawHeight = parseFloat(u.over.style.height); u.setCursor({ left: drawWidth / 2, top: drawHeight / 2 }); }); vizLayoutViz.addEventListener('blur', (e) => { keysLastHandledAt = null; dragStartX = null; pressedKeys.clear(); u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); }); }; /** * @alpha */ export const KeyboardPlugin: React.FC = ({ config }) => { useLayoutEffect(() => config.addHook('init', initHook), [config]); return null; };