diff --git a/src/keyboard/Keyboard.tsx b/src/keyboard/Keyboard.tsx index e8ffaf7..4af0862 100644 --- a/src/keyboard/Keyboard.tsx +++ b/src/keyboard/Keyboard.tsx @@ -29,8 +29,7 @@ import { BehaviorBindingPicker } from "../behaviors/BehaviorBindingPicker"; import { produce } from "immer"; import { LockStateContext } from "../rpc/LockStateContext"; import { LockState } from "@zmkfirmware/zmk-studio-ts-client/core"; -import { deserializeLayoutZoom, LayoutZoom } from "./PhysicalLayout"; -import { useLocalStorageState } from "../misc/useLocalStorageState"; +import { KeyboardViewport } from "./KeyboardViewport"; type BehaviorMap = Record; @@ -174,10 +173,6 @@ export default function Keyboard() { true ); - const [keymapScale, setKeymapScale] = useLocalStorageState("keymapScale", "auto", { - deserialize: deserializeLayoutZoom, - }); - const [selectedLayerIndex, setSelectedLayerIndex] = useState(0); const [selectedKeyPosition, setSelectedKeyPosition] = useState< number | undefined @@ -520,33 +515,17 @@ export default function Keyboard() { )} {layouts && keymap && behaviors && ( -
- - +
+ + +
)} {keymap && selectedBinding && ( diff --git a/src/keyboard/KeyboardViewport.tsx b/src/keyboard/KeyboardViewport.tsx new file mode 100644 index 0000000..bc4913b --- /dev/null +++ b/src/keyboard/KeyboardViewport.tsx @@ -0,0 +1,141 @@ +import { FC, PropsWithChildren, useEffect, useRef } from "react"; +import { useLocalStorageState } from "../misc/useLocalStorageState"; +import { ExpandIcon, MaximizeIcon, ShrinkIcon } from "lucide-react"; + +type KeyboardViewportType = PropsWithChildren<{ + className?: string; +}>; + +const KEYMAP_SCALE = "keymapScale"; +const DEFAULT_SCALE = 1; + +export const KeyboardViewport: FC = ({ + children, + className, +}) => { + const targetRef = useRef(null); + + const [scale, setScale] = useLocalStorageState(KEYMAP_SCALE, DEFAULT_SCALE); + + const resetScale = () => { + if (!targetRef.current) return; + targetRef.current.style.translate = "unset"; + targetRef.current.style.setProperty("transform", "scale(1)"); + setScale(DEFAULT_SCALE); + }; + + useEffect(() => { + if (!targetRef.current) return; + + const target = targetRef.current; + const offset = { x: 0, y: 0 }; + let isPanningActive = false; + + function keyDownPanStart(e: KeyboardEvent) { + if (e.key !== " ") return; + e.preventDefault(); + + target.style.cursor = "grab"; + isPanningActive = true; + } + + function pointerDownPanStart(e: PointerEvent) { + if (e.button !== 0) return; + e.preventDefault(); + + target.style.cursor = "grab"; + isPanningActive = true; + } + + function keyUpPanEnd(e: KeyboardEvent) { + if (e.key !== " ") return; + isPanningActive = false; + target.style.cursor = "unset"; + } + + function panMove(e: PointerEvent) { + if (!isPanningActive) return; + offset.x += e.movementX; + offset.y += e.movementY; + target.style.translate = `${offset.x}px ${offset.y}px`; + } + + function pointerUpPanEnd() { + isPanningActive = false; + target.style.cursor = "unset"; + } + + document.addEventListener("keydown", keyDownPanStart); + document.addEventListener("keyup", keyUpPanEnd); + + target.addEventListener("pointermove", panMove); + target.addEventListener("pointerdown", pointerDownPanStart); + target.addEventListener("pointerup", pointerUpPanEnd); + target.addEventListener("pointerleave", pointerUpPanEnd); + + return () => { + document.removeEventListener("keydown", keyDownPanStart); + document.removeEventListener("keyup", keyUpPanEnd); + + target.removeEventListener("pointermove", panMove); + target.removeEventListener("pointerdown", pointerDownPanStart); + target.removeEventListener("pointerup", pointerUpPanEnd); + target.removeEventListener("pointerleave", pointerUpPanEnd); + }; + }, []); + + return ( +
+
+ {children} +
+ +
+ +
+ setScale(Number(e.target.value))} + /> +
+ + +
+
+ ); +}; diff --git a/src/keyboard/Keymap.tsx b/src/keyboard/Keymap.tsx index 4d457b1..2db6617 100644 --- a/src/keyboard/Keymap.tsx +++ b/src/keyboard/Keymap.tsx @@ -16,7 +16,7 @@ export interface KeymapProps { layout: PhysicalLayout; keymap: KeymapMsg; behaviors: BehaviorMap; - scale: LayoutZoom; + scale?: LayoutZoom; selectedLayerIndex: number; selectedKeyPosition: number | undefined; onKeyPositionClicked: (keyPosition: number) => void; diff --git a/src/keyboard/PhysicalLayout.tsx b/src/keyboard/PhysicalLayout.tsx index 6c7d98c..9d05847 100644 --- a/src/keyboard/PhysicalLayout.tsx +++ b/src/keyboard/PhysicalLayout.tsx @@ -1,9 +1,7 @@ import { CSSProperties, PropsWithChildren, - useLayoutEffect, useRef, - useState, } from "react"; import { Key } from "./Key"; @@ -78,41 +76,6 @@ export const PhysicalLayout = ({ ...props }: PhysicalLayoutProps) => { const ref = useRef(null); - const [scale, setScale] = useState(1); - - useLayoutEffect(() => { - const element = ref.current; - if (!element) return; - - const parent = element.parentElement; - if (!parent) return; - - const calculateScale = () => { - if (props.zoom === "auto") { - const padding = Math.min(window.innerWidth, window.innerHeight) * 0.05; // Padding when in auto mode - const newScale = Math.min( - parent.clientWidth / (element.clientWidth + 2 * padding), - parent.clientHeight / (element.clientHeight + 2 * padding), - ); - setScale(newScale); - } else { - setScale(props.zoom || 1); - } - }; - - calculateScale(); // Initial calculation - - const resizeObserver = new ResizeObserver(() => { - calculateScale(); - }); - - resizeObserver.observe(element); - resizeObserver.observe(parent); - - return () => { - resizeObserver.disconnect(); - }; - }, [props.zoom]); // TODO: Add a bit of padding for rotation when supported let rightMost = positions @@ -145,7 +108,6 @@ export const PhysicalLayout = ({ style={{ height: bottomMost * oneU + "px", width: rightMost * oneU + "px", - transform: `scale(${scale})`, }} ref={ref} {...props} diff --git a/src/misc/useLocalStorageState.ts b/src/misc/useLocalStorageState.ts index e066212..064be3c 100644 --- a/src/misc/useLocalStorageState.ts +++ b/src/misc/useLocalStorageState.ts @@ -1,10 +1,15 @@ import { useEffect, useState } from "react"; function basicSerialize(value: T): string { - if (typeof value === "object") { - return JSON.stringify(value); + return typeof value !== "string" ? JSON.stringify(value) : String(value); +} + +function toJson(value: string): T { + try { + return JSON.parse(value) as T; + } catch { + return value as T; } - return String(value); } export function useLocalStorageState( @@ -14,25 +19,22 @@ export function useLocalStorageState( serialize?: (value: T) => string; deserialize?: (value: string) => T; }, -) { - const reactState = useState(() => { - const savedValue = localStorage.getItem(key); - if (savedValue !== null) { - if (options?.deserialize) { - return options.deserialize(savedValue); - } - return savedValue as T; // Assuming T is a string - } - return defaultValue; - }); +): [T, React.Dispatch>] { + const [state, setState] = useState(() => { + const saved = localStorage.getItem(key); - const [state] = reactState; + if (saved === null) return defaultValue; + return ( + options?.deserialize?.(saved) ?? (toJson(saved) as T) ?? defaultValue + ); + }); useEffect(() => { - const serializedState = - options?.serialize?.(state) || basicSerialize(state); - localStorage.setItem(key, serializedState); - }, [state, key, options]); + localStorage.setItem( + key, + options?.serialize?.(state) ?? basicSerialize(state), + ); + }, [key, state, options]); - return reactState; + return [state, setState]; }