import { AffineTransform, Point, Rect, Size, roundPoint, } from "@noya-app/noya-geometry"; import { clamp } from "@noya-app/noya-utils"; import { Dispatch, SetStateAction } from "react"; import { Easing, animateValue } from "../utils/animationUtils"; import { wheelValues } from "../utils/wheelUtils"; import { Zoom, ZoomRange } from "../utils/zoomUtils"; import { createCanvasPlugin } from "./baseCanvas"; import { BaseContextProperties, HandleKeyboardEventProperties, } from "./contextProperties"; import { NoneMode } from "./PluginSystem"; export type ZoomMode = NoneMode; export type ZoomData = { zoom: number; size: Size; origin: Point; }; export interface ZoomContextProperties extends BaseContextProperties, HandleKeyboardEventProperties {} export type ZoomPluginOptions = { range?: [number, number]; onWheelShouldPreventDefault?: boolean; /** @default 0.02 */ scaleFactor?: number; }; export function zoomPlugin(options: ZoomPluginOptions = {}) { const { range = [Zoom.min, Zoom.max], onWheelShouldPreventDefault = false, scaleFactor = 0.02, } = options; return createCanvasPlugin({ name: "zoom", data: { zoom: 1, size: { width: 0, height: 0 }, origin: { x: 0, y: 0 }, }, handlers: ({ handleKeyboardEvent, getData, setData, }: ZoomContextProperties) => ({ onWheel: (event) => { if (!(event.metaKey || event.ctrlKey)) return; const data = getData(); const delta = wheelValues(event.nativeEvent); const zoomFactor = 1 - delta.y * scaleFactor; const newZoom = setZoom(data, zoomFactor, range, "multiply"); setData(newZoom); if (onWheelShouldPreventDefault) { event.preventDefault(); } }, onKeyDown: (event) => { handleKeyboardEvent({ "Mod-=": () => setData(zoomIn(getData(), range)), "Mod--": () => setData(zoomOut(getData(), range)), "Mod-0": () => setData(zoom0(getData(), range)), })(event); }, }), }); } export function setZoom( data: Data, value: number, range: ZoomRange, mode?: "replace" | "multiply" | "multiplyDiscrete" ): Data { const { size, origin, zoom } = data; let newZoom; switch (mode) { case "multiplyDiscrete": newZoom = Zoom.clamp(Zoom.nearestLevel(value * zoom)); break; case "multiply": newZoom = Zoom.clamp(value * zoom); break; default: newZoom = Zoom.clamp(value); } newZoom = clamp(newZoom, range[0], range[1]); const viewportCenter = { x: size.width / 2, y: size.height / 2, }; // To find the new scrollOrigin: start at the viewport center and // move by the scaled the distance to the scrollOrigin const newOrigin = roundPoint( AffineTransform.translate( (origin.x - viewportCenter.x) * (newZoom / zoom), (origin.y - viewportCenter.y) * (newZoom / zoom) ).applyTo(viewportCenter) ); return { ...data, zoom: newZoom, origin: newOrigin, }; } export function zoomIn( data: Data, range: ZoomRange ): Data { return setZoom(data, 2, range, "multiplyDiscrete"); } export function zoomOut( data: Data, range: ZoomRange ): Data { return setZoom(data, 0.5, range, "multiplyDiscrete"); } export function zoom0( data: Data, range: ZoomRange ): Data { return setZoom(data, 1, range, "replace"); } export type ZoomAnimatedOptions = { zoom: number; range: ZoomRange; setData: Dispatch>; duration?: number; easing?: (progress: number) => number; }; export const defaultZoomAnimationDuration = 150; export const defaultZoomAnimationEasing = Easing.easeInOut; export function zoomInAnimated({ zoom, range, setData, duration = defaultZoomAnimationDuration, easing = defaultZoomAnimationEasing, }: ZoomAnimatedOptions) { animateValue({ start: zoom, end: Zoom.nearestLevel(zoom * 2), duration, easing, onChange: (value) => { setData((data) => setZoom(data, value, range)); }, }); } export function zoomOutAnimated({ zoom, range, setData, duration = defaultZoomAnimationDuration, easing = defaultZoomAnimationEasing, }: ZoomAnimatedOptions) { animateValue({ start: zoom, end: Zoom.nearestLevel(zoom * 0.5), duration, easing, onChange: (value) => { setData((data) => setZoom(data, value, range)); }, }); } export function zoom0Animated({ zoom, range, setData, duration = defaultZoomAnimationDuration, easing = defaultZoomAnimationEasing, }: ZoomAnimatedOptions) { animateValue({ start: zoom, end: 1, duration, easing, onChange: (value) => { setData((data) => setZoom(data, value, range)); }, }); } export function zoomToFit({ data, rect, range, padding = 0, }: { data: Data; rect: Rect; range?: ZoomRange; padding?: number; }): Data { const paddedCanvasSize = { width: data.size.width - padding * 2, height: data.size.height - padding * 2, }; let zoom = Math.min( paddedCanvasSize.width / rect.width, paddedCanvasSize.height / rect.height ); if (range) { zoom = clamp(zoom, range[0], range[1]); } // Center the rect within the padded canvas area const origin = { x: paddedCanvasSize.width / 2 + padding - (rect.x + rect.width / 2) * zoom, y: paddedCanvasSize.height / 2 + padding - (rect.y + rect.height / 2) * zoom, }; return { ...data, zoom, origin }; } export function zoomToFitAnimated({ data, rect, padding, range, duration = defaultZoomAnimationDuration, easing = defaultZoomAnimationEasing, setData, }: { data: Data; rect: Rect; padding?: number; range: ZoomRange; duration?: number; easing?: (progress: number) => number; setData: Dispatch>; }): void { const newData = zoomToFit({ data, rect, range, padding }); animateValue({ start: data.zoom, end: newData.zoom, duration, easing, onChange: (value) => { setData((data) => ({ ...data, zoom: value })); }, }); animateValue({ start: data.origin.x, end: newData.origin.x, duration, easing, onChange: (value) => { setData((data) => ({ ...data, origin: { ...data.origin, x: value } })); }, }); animateValue({ start: data.origin.y, end: newData.origin.y, duration, easing, onChange: (value) => { setData((data) => ({ ...data, origin: { ...data.origin, y: value } })); }, }); }