Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(zoom): making zoom in+out a usable feature #116

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 12 additions & 33 deletions src/keyboard/Keyboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useLocalStorageState was created for future local storage uses. I don't think we should remove it #31 (comment)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restored useLocalStorageState, i slightly modified the hook to have default serialize+deserializer, imho, these options should only be required for specific serialize+deserializer functions. lmk what you think ^^

import { KeyboardViewport } from "./KeyboardViewport";

type BehaviorMap = Record<number, GetBehaviorDetailsResponse>;

Expand Down Expand Up @@ -174,10 +173,6 @@ export default function Keyboard() {
true
);

const [keymapScale, setKeymapScale] = useLocalStorageState<LayoutZoom>("keymapScale", "auto", {
deserialize: deserializeLayoutZoom,
});

const [selectedLayerIndex, setSelectedLayerIndex] = useState<number>(0);
const [selectedKeyPosition, setSelectedKeyPosition] = useState<
number | undefined
Expand Down Expand Up @@ -520,33 +515,17 @@ export default function Keyboard() {
)}
</div>
{layouts && keymap && behaviors && (
<div className="p-2 col-start-2 row-start-1 grid items-center justify-center relative min-w-0">
<KeymapComp
keymap={keymap}
layout={layouts[selectedPhysicalLayoutIndex]}
behaviors={behaviors}
scale={keymapScale}
selectedLayerIndex={selectedLayerIndex}
selectedKeyPosition={selectedKeyPosition}
onKeyPositionClicked={setSelectedKeyPosition}
/>
<select
className="absolute top-2 right-2 h-8 rounded px-2"
value={keymapScale}
onChange={(e) => {
const value = deserializeLayoutZoom(e.target.value);
setKeymapScale(value);
}}
>
<option value="auto">Auto</option>
<option value={0.25}>25%</option>
<option value={0.5}>50%</option>
<option value={0.75}>75%</option>
<option value={1}>100%</option>
<option value={1.25}>125%</option>
<option value={1.5}>150%</option>
<option value={2}>200%</option>
</select>
<div className="col-start-2 row-start-1 flex items-center justify-center relative min-w-0">
<KeyboardViewport>
<KeymapComp
keymap={keymap}
layout={layouts[selectedPhysicalLayoutIndex]}
behaviors={behaviors}
selectedLayerIndex={selectedLayerIndex}
selectedKeyPosition={selectedKeyPosition}
onKeyPositionClicked={setSelectedKeyPosition}
/>
</KeyboardViewport>
</div>
)}
{keymap && selectedBinding && (
Expand Down
141 changes: 141 additions & 0 deletions src/keyboard/KeyboardViewport.tsx
Original file line number Diff line number Diff line change
@@ -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<KeyboardViewportType> = ({
pongstr marked this conversation as resolved.
Show resolved Hide resolved
children,
className,
}) => {
const targetRef = useRef<HTMLDivElement>(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 (
<div
className={[
"relative size-full overflow-hidden p-0 touch-none",
className,
].join(" ")}
>
<div
ref={targetRef}
style={{ transform: `scale(${scale})` }}
className="flex size-full origin-center items-center justify-center transition-transform"
>
{children}
</div>

<div className="absolute bottom-8 left-0 flex justify-center items-center w-full gap-1 rounded-xl bg-muted py-1 select-none bg-base-300">
<button
className="block h-9 px-4 py-1.5 bg-base-100 rounded-l-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={scale <= 0.25}
onClick={() => setScale((prev: number) => prev - 0.05)}
>
<ShrinkIcon className="size-4" />
<span className="sr-only">Decrease scale</span>
</button>
<div className="flex h-9 px-2 justify-center items-center bg-base-100">
<input
type="range"
name="scale"
min={0.25}
max={2}
step={0.01}
className="mx-auto h-1 w-28 cursor-pointer appearance-none rounded-lg"
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
/>
</div>
<button
className="block h-9 px-4 py-1.5 bg-base-100 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={scale >= 2}
onClick={() => setScale((prev: number) => prev + 0.05)}
>
<ExpandIcon className="size-4" />
<span className="sr-only">Increase scale</span>
</button>
<button
className="block px-4 py-1.5 bg-base-100 rounded-r-lg h-9 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={resetScale}
>
<MaximizeIcon className="size-4" />
<span className="sr-only">Reset scale</span>
</button>
</div>
</div>
);
};
2 changes: 1 addition & 1 deletion src/keyboard/Keymap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 0 additions & 38 deletions src/keyboard/PhysicalLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {
CSSProperties,
PropsWithChildren,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Key } from "./Key";

Expand Down Expand Up @@ -78,41 +76,6 @@ export const PhysicalLayout = ({
...props
}: PhysicalLayoutProps) => {
const ref = useRef<HTMLDivElement>(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
Expand Down Expand Up @@ -145,7 +108,6 @@ export const PhysicalLayout = ({
style={{
height: bottomMost * oneU + "px",
width: rightMost * oneU + "px",
transform: `scale(${scale})`,
}}
ref={ref}
{...props}
Expand Down
42 changes: 22 additions & 20 deletions src/misc/useLocalStorageState.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { useEffect, useState } from "react";

function basicSerialize<T>(value: T): string {
if (typeof value === "object") {
return JSON.stringify(value);
return typeof value !== "string" ? JSON.stringify(value) : String(value);
}

function toJson<T>(value: string): T {
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
return String(value);
}

export function useLocalStorageState<T>(
Expand All @@ -14,25 +19,22 @@ export function useLocalStorageState<T>(
serialize?: (value: T) => string;
deserialize?: (value: string) => T;
},
) {
const reactState = useState<T>(() => {
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<React.SetStateAction<T>>] {
const [state, setState] = useState<T>(() => {
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];
}