From c354b725e0f0de00c809ff5aaddea171d031b8d2 Mon Sep 17 00:00:00 2001 From: Allen Date: Fri, 14 Feb 2025 15:22:59 +0800 Subject: [PATCH] feat: support the canvas zoom event on mobile devices (#6768) * feat: support the canvas zoom event on mobile devices * chore: remove code of mobile, unify the canvas zooming logic * chore: delete redundant SVG files for zoom canvas * chore: update variable name of pointer event * fix: unique zoom canvas interface of g6 and g6-extension-3d * refactor(behavior): refactor code of zoom canvas for pointer event * refactor(behavior): defined pinch event and refactor shortcut code * chore: optimize code naming and parameter definition * chore: optimize bind code of shortcut --- .../src/behaviors/zoom-canvas-3d.ts | 10 +- .../behaviors/zoom-canvas/mobile-final.svg | 538 ++++++++++++++++++ .../behaviors/zoom-canvas/mobile-initial.svg | 538 ++++++++++++++++++ .../unit/behaviors/zoom-canvas.spec.ts | 72 +++ packages/g6/src/behaviors/zoom-canvas.ts | 32 +- packages/g6/src/constants/events/common.ts | 6 + packages/g6/src/utils/pinch.ts | 176 ++++++ packages/g6/src/utils/shortcut.ts | 11 + 8 files changed, 1372 insertions(+), 11 deletions(-) create mode 100644 packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-final.svg create mode 100644 packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-initial.svg create mode 100644 packages/g6/src/utils/pinch.ts diff --git a/packages/g6-extension-3d/src/behaviors/zoom-canvas-3d.ts b/packages/g6-extension-3d/src/behaviors/zoom-canvas-3d.ts index c07339c2781..e7e4a6db819 100644 --- a/packages/g6-extension-3d/src/behaviors/zoom-canvas-3d.ts +++ b/packages/g6-extension-3d/src/behaviors/zoom-canvas-3d.ts @@ -1,4 +1,10 @@ -import type { IKeyboardEvent, IWheelEvent, ViewportAnimationEffectTiming, ZoomCanvasOptions } from '@antv/g6'; +import type { + IKeyboardEvent, + IPointerEvent, + IWheelEvent, + ViewportAnimationEffectTiming, + ZoomCanvasOptions, +} from '@antv/g6'; import { ZoomCanvas } from '@antv/g6'; import { clamp } from '@antv/util'; @@ -17,7 +23,7 @@ export interface ZoomCanvas3DOptions extends ZoomCanvasOptions {} export class ZoomCanvas3D extends ZoomCanvas { protected zoom = async ( value: number, - event: IWheelEvent | IKeyboardEvent, + event: IWheelEvent | IKeyboardEvent | IPointerEvent, animation: ViewportAnimationEffectTiming | undefined, ) => { if (!this.validate(event)) return; diff --git a/packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-final.svg b/packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-final.svg new file mode 100644 index 00000000000..6242c77f442 --- /dev/null +++ b/packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-final.svg @@ -0,0 +1,538 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-initial.svg b/packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-initial.svg new file mode 100644 index 00000000000..c2279aa3b93 --- /dev/null +++ b/packages/g6/__tests__/snapshots/behaviors/zoom-canvas/mobile-initial.svg @@ -0,0 +1,538 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/g6/__tests__/unit/behaviors/zoom-canvas.spec.ts b/packages/g6/__tests__/unit/behaviors/zoom-canvas.spec.ts index 7c20f9252eb..001d7c929d3 100644 --- a/packages/g6/__tests__/unit/behaviors/zoom-canvas.spec.ts +++ b/packages/g6/__tests__/unit/behaviors/zoom-canvas.spec.ts @@ -40,6 +40,78 @@ describe('behavior zoom canvas', () => { expect(graph.getZoom()).toBeCloseTo(currentZoom * 0.95 ** 2); }); + it('mobile zoom', async () => { + const initZoom = graph.getZoom(); + const canvas = graph.getCanvas(); + const container = canvas.getContainer(); + if (!container) return; + + const initialBehaviors = graph.getBehaviors(); + graph.setBehaviors([{ type: 'zoom-canvas' }, { type: 'zoom-canvas', trigger: ['pinch'] }]); + + const pointerdownListener = jest.fn(); + const pointermoveListener = jest.fn(); + + const pointerByTouch = [ + { + client: { + x: 100, + y: 100, + }, + pointerId: 1, + pointerType: 'touch', + }, + { + client: { + x: 200, + y: 200, + }, + pointerId: 2, + pointerType: 'touch', + }, + ]; + + const dxForInitial = pointerByTouch[0].client.x - pointerByTouch[1].client.x; + const dyForInitial = pointerByTouch[0].client.y - pointerByTouch[1].client.y; + const initialDistance = Math.sqrt(dxForInitial * dxForInitial + dyForInitial * dyForInitial); + + await expect(graph).toMatchSnapshot(__filename, 'mobile-initial'); + + graph.once('canvas:pointerdown', pointerdownListener); + canvas.document.emit(CommonEvent.POINTER_DOWN, { client: { x: 100, y: 100 } }); + expect(pointerdownListener).toHaveBeenCalledTimes(1); + + graph.once('canvas:pointermove', pointermoveListener); + canvas.document.emit(CommonEvent.POINTER_MOVE, { client: { x: 200, y: 200 } }); + expect(pointermoveListener).toHaveBeenCalledTimes(1); + + pointerByTouch[1] = { + client: { + x: 250, + y: 250, + }, + pointerId: 2, + pointerType: 'touch', + }; + + const dxForMove = pointerByTouch[0].client.x - pointerByTouch[1].client.x; + const dyForMove = pointerByTouch[0].client.y - pointerByTouch[1].client.y; + const currentDistance = Math.sqrt(dxForMove * dxForMove + dyForMove * dyForMove); + const ratio = currentDistance / initialDistance; + const value = (ratio - 1) * 5; + + await graph.zoomTo(initZoom * value, false, undefined); + expect(graph.getZoom()).not.toBe(initZoom); + + await expect(graph).toMatchSnapshot(__filename, 'mobile-final'); + + await graph.zoomTo(initZoom, false, undefined); + expect(graph.getZoom()).toBe(initZoom); + + graph.setBehaviors(initialBehaviors); + expect(graph.getBehaviors()).toEqual([{ type: 'zoom-canvas' }]); + }); + const shortcutZoomCanvasOptions: ZoomCanvasOptions = { key: 'shortcut-zoom-canvas', type: 'zoom-canvas', diff --git a/packages/g6/src/behaviors/zoom-canvas.ts b/packages/g6/src/behaviors/zoom-canvas.ts index 171bbe3c5f5..d40a49d2aad 100644 --- a/packages/g6/src/behaviors/zoom-canvas.ts +++ b/packages/g6/src/behaviors/zoom-canvas.ts @@ -1,7 +1,14 @@ import { clamp, isFunction } from '@antv/util'; import { CommonEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; -import type { IKeyboardEvent, IWheelEvent, Point, PointObject, ViewportAnimationEffectTiming } from '../types'; +import type { + IKeyboardEvent, + IPointerEvent, + IWheelEvent, + Point, + PointObject, + ViewportAnimationEffectTiming, +} from '../types'; import { parsePoint } from '../utils/point'; import type { ShortcutKey } from '../utils/shortcut'; import { Shortcut } from '../utils/shortcut'; @@ -27,7 +34,7 @@ export interface ZoomCanvasOptions extends BaseBehaviorOptions { * Whether to enable the function of zooming the canvas * @defaultValue true */ - enable?: boolean | ((event: IWheelEvent | IKeyboardEvent) => boolean); + enable?: boolean | ((event: IWheelEvent | IKeyboardEvent | IPointerEvent) => boolean); /** * 触发缩放的方式 * - ShortcutKey:组合快捷键,**默认使用滚轮缩放**,['Control'] 表示按住 Control 键滚动鼠标滚轮时触发缩放 @@ -107,11 +114,18 @@ export class ZoomCanvas extends BaseBehavior { this.shortcut.unbindAll(); if (Array.isArray(trigger)) { - this.context.canvas.getContainer()?.addEventListener(CommonEvent.WHEEL, this.preventDefault); - this.shortcut.bind([...trigger, CommonEvent.WHEEL], (event) => { - const { deltaX, deltaY } = event; - this.zoom(-(deltaY ?? deltaX), event, false); - }); + if (trigger.includes(CommonEvent.PINCH)) { + this.shortcut.bind([CommonEvent.PINCH], (event) => { + this.zoom(event.scale, event, false); + }); + } else { + const container = this.context.canvas.getContainer(); + container?.addEventListener(CommonEvent.WHEEL, this.preventDefault); + this.shortcut.bind([...trigger, CommonEvent.WHEEL], (event) => { + const { deltaX, deltaY } = event; + this.zoom(-(deltaY ?? deltaX), event, false); + }); + } } if (typeof trigger === 'object') { @@ -140,7 +154,7 @@ export class ZoomCanvas extends BaseBehavior { */ protected zoom = async ( value: number, - event: IWheelEvent | IKeyboardEvent, + event: IWheelEvent | IKeyboardEvent | IPointerEvent, animation: ZoomCanvasOptions['animation'], ) => { if (!this.validate(event)) return; @@ -171,7 +185,7 @@ export class ZoomCanvas extends BaseBehavior { * @returns 是否可以缩放 | Whether it can be zoomed * @internal */ - protected validate(event: IWheelEvent | IKeyboardEvent) { + protected validate(event: IWheelEvent | IKeyboardEvent | IPointerEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); diff --git a/packages/g6/src/constants/events/common.ts b/packages/g6/src/constants/events/common.ts index 340fb0cb594..ec09044794b 100644 --- a/packages/g6/src/constants/events/common.ts +++ b/packages/g6/src/constants/events/common.ts @@ -119,4 +119,10 @@ export enum CommonEvent { * Triggered when scrolling */ WHEEL = 'wheel', + /** + * 双指捏拢或张开时触发 + * + * Triggered when pinch in and pinch out + */ + PINCH = 'pinch', } diff --git a/packages/g6/src/utils/pinch.ts b/packages/g6/src/utils/pinch.ts new file mode 100644 index 00000000000..a1df1655e67 --- /dev/null +++ b/packages/g6/src/utils/pinch.ts @@ -0,0 +1,176 @@ +import EventEmitter from '@antv/event-emitter'; +import { CommonEvent } from '../constants'; +import { IPointerEvent } from '../types'; + +/** + * 表示指针位置的点坐标 + * + * Represents the coordinates of a pointer position + */ +export interface PointerPoint { + x: number; + y: number; + pointerId: number; +} + +/** + * 捏合事件参数 + * + * Pinch event parameters + * @remarks + * 包含与捏合手势相关的参数,当前支持缩放比例,未来可扩展中心点坐标、旋转角度等参数 + * + * Contains parameters related to pinch gestures, currently supports scale factor, + * can be extended with center coordinates, rotation angle etc. in the future + */ +export interface PinchEventOptions { + /** + * 缩放比例因子,>1 表示放大,<1 表示缩小 + * + * Scaling factor, >1 indicates zoom in, <1 indicates zoom out + */ + scale: number; +} + +/** + * 捏合手势回调函数类型 + * + * Pinch gesture callback function type + * @param event - 原始指针事件对象 | Original pointer event object + * @param options - 捏合事件参数对象 | Pinch event parameters object + */ +export type PinchCallback = (event: IPointerEvent, options: PinchEventOptions) => void; + +/** + * 捏合手势处理器 + * + * Pinch gesture handler + * @remarks + * 处理双指触摸事件,计算缩放比例并触发回调。通过跟踪两个触摸点的位置变化,计算两点间距离变化率来确定缩放比例。 + * + * Handles two-finger touch events, calculates zoom ratio and triggers callbacks. Tracks position changes of two touch points to determine zoom ratio based on distance variation. + */ +export class PinchHandler { + /** + * 当前跟踪的触摸点集合 + * + * Currently tracked touch points collection + */ + private pointerByTouch: PointerPoint[] = []; + + /** + * 初始两点间距离 + * + * Initial distance between two points + */ + private initialDistance: number | null = null; + + private emitter: EventEmitter; + + constructor( + emitter: EventEmitter, + private callback: PinchCallback, + ) { + this.emitter = emitter; + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.bindEvents(); + } + + private bindEvents() { + const { emitter } = this; + emitter.on(CommonEvent.POINTER_DOWN, this.onPointerDown); + emitter.on(CommonEvent.POINTER_MOVE, this.onPointerMove); + emitter.on(CommonEvent.POINTER_UP, this.onPointerUp); + } + + /** + * 更新指定指针的位置 + * + * Update position of specified pointer + * @param pointerId - 指针唯一标识符 | Pointer unique identifier1 + * @param x - 新的X坐标 | New X coordinate + * @param y - 新的Y坐标 | New Y coordinate + */ + private updatePointerPosition(pointerId: number, x: number, y: number) { + const index = this.pointerByTouch.findIndex((p) => p.pointerId === pointerId); + if (index >= 0) { + this.pointerByTouch[index] = { x, y, pointerId }; + } + } + + /** + * 处理指针按下事件 + * + * Handle pointer down event + * @param event - 指针事件对象 | Pointer event object + * @remarks + * 当检测到两个触摸点时记录初始距离 + * + * Record initial distance when detecting two touch points + */ + onPointerDown(event: IPointerEvent) { + const { x, y } = event.client; + if (x === undefined || y === undefined) return; + this.pointerByTouch.push({ x, y, pointerId: event.pointerId }); + + if (event.pointerType === 'touch' && this.pointerByTouch.length === 2) { + const dx = this.pointerByTouch[0].x - this.pointerByTouch[1].x; + const dy = this.pointerByTouch[0].y - this.pointerByTouch[1].y; + this.initialDistance = Math.sqrt(dx * dx + dy * dy); + } + } + + /** + * 处理指针移动事件 + * + * Handle pointer move event + * @param event - 指针事件对象 | Pointer event object + * @remarks + * 当存在两个有效触摸点时计算缩放比例 + * + * Calculate zoom ratio when two valid touch points exist + */ + onPointerMove(event: IPointerEvent) { + if (this.pointerByTouch.length !== 2 || this.initialDistance === null) return; + const { x, y } = event.client; + if (x === undefined || y === undefined) return; + this.updatePointerPosition(event.pointerId, x, y); + const dx = this.pointerByTouch[0].x - this.pointerByTouch[1].x; + const dy = this.pointerByTouch[0].y - this.pointerByTouch[1].y; + const currentDistance = Math.sqrt(dx * dx + dy * dy); + const ratio = currentDistance / this.initialDistance; + + this.callback(event, { scale: (ratio - 1) * 5 }); + } + + /** + * 处理指针抬起事件 + * + * Handle pointer up event + * @remarks + * 重置触摸状态和初始距离 + * + * Reset touch state and initial distance + */ + onPointerUp() { + this.initialDistance = null; + this.pointerByTouch = []; + } + + /** + * 销毁捏合手势相关监听 + * + * Destroy pinch gesture listeners + * @remarks + * 移除指针按下、移动、抬起事件的监听 + * + * Remove listeners for pointer down, move, and up events + */ + destroy() { + this.emitter.off(CommonEvent.POINTER_DOWN, this.onPointerDown); + this.emitter.off(CommonEvent.POINTER_MOVE, this.onPointerMove); + this.emitter.off(CommonEvent.POINTER_UP, this.onPointerUp); + } +} diff --git a/packages/g6/src/utils/shortcut.ts b/packages/g6/src/utils/shortcut.ts index c19c998d548..55068bf8c1d 100644 --- a/packages/g6/src/utils/shortcut.ts +++ b/packages/g6/src/utils/shortcut.ts @@ -2,6 +2,8 @@ import EventEmitter from '@antv/event-emitter'; import type { FederatedMouseEvent } from '@antv/g'; import { isEqual, isString } from '@antv/util'; import { CommonEvent } from '../constants'; +import type { PinchCallback } from './pinch'; +import { PinchHandler } from './pinch'; export interface ShortcutOptions {} @@ -13,6 +15,7 @@ const lowerCaseKeys = (keys: ShortcutKey) => keys.map((key) => (isString(key) ? export class Shortcut { private map: Map = new Map(); + private pinchHandler: PinchHandler | undefined; private emitter: EventEmitter; @@ -25,6 +28,9 @@ export class Shortcut { public bind(key: ShortcutKey, handler: Handler) { if (key.length === 0) return; + if (key.includes(CommonEvent.PINCH) && !this.pinchHandler) { + this.pinchHandler = new PinchHandler(this.emitter, this.handlePinch.bind(this)); + } this.map.set(key, handler); } @@ -107,6 +113,10 @@ export class Shortcut { this.triggerExtendKey(CommonEvent.DRAG, event); }; + private handlePinch: PinchCallback = (event, options) => { + this.triggerExtendKey(CommonEvent.PINCH, { ...event, ...options }); + }; + private onFocus = () => { this.recordKey.clear(); }; @@ -117,6 +127,7 @@ export class Shortcut { this.emitter.off(CommonEvent.KEY_UP, this.onKeyUp); this.emitter.off(CommonEvent.WHEEL, this.onWheel); this.emitter.off(CommonEvent.DRAG, this.onDrag); + this.pinchHandler?.destroy(); globalThis.removeEventListener?.('blur', this.onFocus); } }