Changes
3 changed files (+164/-5)
-
-
@@ -1,6 +1,6 @@import { attr, className, el, on, style, svg } from "../dom.js"; import * as figma from "../figma.js"; import { roundTo } from "../math.js"; import { roundTo, nextPowerOfTwo, previousPowerOfTwo } from "../math.js"; import { type Preferences } from "../preferences.js"; import { effect, Signal } from "../signal.js";
-
@@ -25,6 +25,9 @@ const enum TouchingStateModes {Scaling, } const MIN_SCALE = 2 ** -6; const MAX_SCALE = 2 ** 8; interface Panning { mode: TouchingStateModes.Panning; initialTouch: Touch;
-
@@ -736,8 +739,15 @@ export class FrameCanvas {const prevScale = this.#scale; this.#scale *= 1 - deltaY / ((1000 - this.#preferences.viewportZoomSpeed) * 0.5); // Clamp scale between MIN_SCALE and MAX_SCALE this.#scale = Math.max( MIN_SCALE, Math.min( MAX_SCALE, this.#scale * (1 - deltaY / ((1000 - this.#preferences.viewportZoomSpeed) * 0.5)), ), ); // Calling layout-read method on every `wheel` event is not desirable. // While `getBoundingClientRect` in here runs immediately according to Chrome performance
-
@@ -779,7 +789,52 @@ export class FrameCanvas {this.#applyTransform(); }; // Handles pan and scale with keyboard shortcuts // arrow keys for pan and -/=(+) for zoom #handleKeyDownPanOrScale = (ev: KeyboardEvent) => { if ( !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "=", "-"].includes( ev.key, ) || ev.ctrlKey ) { return; } ev.preventDefault(); ev.stopPropagation(); // Matches Figma's keyboard zoom behavior // Figma changes current scale to next/previous (in/out) power of two (including negative powers) if (ev.key === "=" || ev.key === "-") { this.#scale = ev.key === "=" ? nextPowerOfTwo(this.#scale, { min: MIN_SCALE, max: MAX_SCALE }) : previousPowerOfTwo(this.#scale, { min: MIN_SCALE, max: MAX_SCALE }); } else { // Figma moves ~65px per keydown, 13 percent of the default viewport pan speed of 500 is 65px; const distance = this.#preferences.viewportPanSpeed * 0.13; this.#x += ev.key === "ArrowLeft" ? distance : ev.key === "ArrowRight" ? -distance : 0; this.#y += ev.key === "ArrowDown" ? -distance : ev.key === "ArrowUp" ? distance : 0; } this.#applyTransform(); }; #onKeyDown = (ev: KeyboardEvent) => { this.#handleKeyDownPanOrScale(ev); if (ev.key !== FrameCanvas.DRAG_MODE_KEY) { return; }
-
-
-
@@ -1,6 +1,5 @@import { describe, expect, it } from "vitest"; import { roundTo } from "./math"; import { roundTo, nextPowerOfTwo, previousPowerOfTwo } from "./math"; describe("roundTo", () => { it("Should round to int without `at` parameter", () => {
-
@@ -13,3 +12,72 @@ describe("roundTo", () => {expect(roundTo(9.87654321, 2)).toBe(9.88); }); }); const options = { min: 2 ** -3, max: 2 ** 3, }; describe("nextPowerOfTwo", () => { it("Should return the next power of two, clamped within min and max zoom", () => { expect(nextPowerOfTwo(options.min, options)).toBe(2 ** -2); expect(nextPowerOfTwo(2 ** -2.5, options)).toBe(2 ** -2); expect(nextPowerOfTwo(2 ** -2, options)).toBe(2 ** -1); expect(nextPowerOfTwo(2 ** -1.5, options)).toBe(2 ** -1); expect(nextPowerOfTwo(2 ** -1, options)).toBe(1); expect(nextPowerOfTwo(2 ** -0.5, options)).toBe(1); expect(nextPowerOfTwo(1, options)).toBe(2); expect(nextPowerOfTwo(1.5, options)).toBe(2); expect(nextPowerOfTwo(2, options)).toBe(4); expect(nextPowerOfTwo(3, options)).toBe(4); expect(nextPowerOfTwo(4, options)).toBe(8); expect(nextPowerOfTwo(5, options)).toBe(8); expect(nextPowerOfTwo(options.max, options)).toBe(options.max); }); it("Should return max zoom if input is greater than max zoom", () => { expect(nextPowerOfTwo(options.max + 1, options)).toBe(options.max); }); it("Should return min zoom if input is less than min zoom", () => { expect(nextPowerOfTwo(options.min - 1, options)).toBe(options.min); }); }); describe("previousPowerOfTwo", () => { it("Should return the previous power of two, clamped within min and max zoom", () => { expect(previousPowerOfTwo(options.max, options)).toBe(4); expect(previousPowerOfTwo(7, options)).toBe(4); expect(previousPowerOfTwo(4, options)).toBe(2); expect(previousPowerOfTwo(3, options)).toBe(2); expect(previousPowerOfTwo(2, options)).toBe(1); expect(previousPowerOfTwo(1.5, options)).toBe(1); expect(previousPowerOfTwo(1, options)).toBe(2 ** -1); expect(previousPowerOfTwo(2 ** -0.5, options)).toBe(2 ** -1); expect(previousPowerOfTwo(2 ** -1, options)).toBe(2 ** -2); expect(previousPowerOfTwo(2 ** -1.5, options)).toBe(2 ** -2); expect(previousPowerOfTwo(2 ** -2, options)).toBe(2 ** -3); expect(previousPowerOfTwo(2 ** -2.5, options)).toBe(2 ** -3); expect(previousPowerOfTwo(options.min, options)).toBe(options.min); }); it("Should return max zoom if input is greater than max zoom", () => { expect(previousPowerOfTwo(options.max + 1, options)).toBe(options.max); }); it("Should return min zoom if input is less than min zoom", () => { expect(previousPowerOfTwo(options.min - 1, options)).toBe(options.min); }); });
-
-
-
@@ -7,3 +7,39 @@ export function roundTo(x: number, to: number = 0) {return Math.round(x * p) / p; } interface PowerOfTwoOptions { min: number; max: number; } export function nextPowerOfTwo( num: number, options: PowerOfTwoOptions, ): number { const nearest = nearestPowerOfTwo(num, options); return Math.min(options.max, nearest > num ? nearest : nearest * 2); } export function previousPowerOfTwo( num: number, options: PowerOfTwoOptions, ): number { const nearest = nearestPowerOfTwo(num, options); return Math.max(options.min, nearest < num ? nearest : nearest / 2); } function nearestPowerOfTwo(num: number, options: PowerOfTwoOptions): number { if (num < options.min) { return options.min; } else if (num > options.max) { return options.max; } if (num < 1) { return 1 / nearestPowerOfTwo(1 / num, options); } const power = Math.round(Math.log(num) / Math.log(2)); return 2 ** power; }
-