diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index a1f0bb3b..293c741e 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -6,6 +6,12 @@ import { ComponentDescriptor } from "../../lib/CoreComponent"; import { getXY, isMetaKeyEvent, isTrackpadWheelEvent, isWindows } from "../../utils/functions"; import { clamp } from "../../utils/functions/clamp"; import { dragListener } from "../../utils/functions/dragListener"; +import { + getEventPageCoordinates, + getTouchCenter, + getTouchDistance, + isTouchDevice, +} from "../../utils/functions/touchUtils"; import { EVENTS } from "../../utils/types/events"; import { ICamera } from "./CameraService"; @@ -16,21 +22,30 @@ export type TCameraProps = TComponentProps & { }; export class Camera extends EventedComponent { - private camera: ICamera; + private static readonly PINCH_THRESHOLD = 0.01; + private static readonly PINCH_DELTA_MULTIPLIER = 100; + private camera: ICamera; private ownerDocument: Document; + private lastDragEvent?: MouseEvent | TouchEvent; - private lastDragEvent?: MouseEvent; + // Touch gesture state + private isTouch: boolean; + private lastPinchDistance?: number; + private isPinching = false; constructor(props: TCameraProps, parent: Component) { super(props, parent); this.camera = this.context.camera; this.ownerDocument = this.context.ownerDocument; + this.isTouch = isTouchDevice(); this.addWheelListener(); this.addEventListener("click", this.handleClick); this.addEventListener("mousedown", this.handleMouseDownEvent); + + this.addTouchEventListeners(); } protected handleClick = () => { @@ -48,6 +63,30 @@ export class Camera extends EventedComponent { @@ -68,22 +108,159 @@ export class Camera extends EventedComponent this.onDragStart(event)) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => this.onDragUpdate(event)) - .on(EVENTS.DRAG_END, () => this.onDragEnd()); + this.startDragListening(); } }; - private onDragStart(event: MouseEvent) { + private handleTouchStartEvent = (event: TouchEvent) => { + // Prevent default browser pinch/zoom behavior for multi-touch events + if (event.touches.length >= 2) { + event.preventDefault(); + } + + if (!this.context.graph.rootStore.settings.getConfigFlag("canDragCamera")) { + return; + } + + if (!this.handlePinchStart(event)) { + this.handleSingleTouchStart(event); + } + }; + + /** + * Handles the start of a pinch gesture + * @param {TouchEvent} event - Touch event + * @returns {boolean} True if pinch was started + */ + private handlePinchStart(event: TouchEvent): boolean { + if (event.touches.length === 2 && this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) { + this.isPinching = true; + this.lastPinchDistance = getTouchDistance(event); + return true; + } + return false; + } + + /** + * Handles the start of a single touch drag + * @param {TouchEvent} event - Touch event + * @returns {boolean} True if drag was started + */ + private handleSingleTouchStart(event: TouchEvent): boolean { + if (event.touches.length === 1 && !this.isPinching) { + this.startDragListening(); + return true; + } + return false; + } + + private handleTouchMoveEvent = (event: TouchEvent) => { + // Prevent default browser behavior for multi-touch to avoid browser zoom + if (event.touches.length >= 2) { + event.preventDefault(); + } + + if (this.isPinching && event.touches.length === 2) { + this.processPinchZoom(event); + } + }; + + /** + * Processes pinch zoom gesture + * @param {TouchEvent} event - Touch event + * @returns {void} + */ + private processPinchZoom(event: TouchEvent): void { + if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera") || !this.lastPinchDistance) { + return; + } + + const currentDistance = getTouchDistance(event); + if (currentDistance <= 0) return; + + const scaleDelta = currentDistance / this.lastPinchDistance; + + // Only apply zoom if there's a meaningful change to avoid jitter + if (Math.abs(scaleDelta - 1) > Camera.PINCH_THRESHOLD) { + this.applyPinchZoom(event, scaleDelta); + } + + this.lastPinchDistance = currentDistance; + } + + /** + * Applies pinch zoom using the same logic as wheel events for consistency + * @param {TouchEvent} event - Touch event + * @param {number} scaleDelta - Scale delta from pinch gesture + * @returns {void} + */ + private applyPinchZoom(event: TouchEvent, scaleDelta: number): void { + const center = getTouchCenter(event); + + // Convert client coordinates to canvas coordinates + const rect = this.context.canvas.getBoundingClientRect(); + const canvasX = center.x - rect.left; + const canvasY = center.y - rect.top; + + // Use the same logic as handleWheelEvent for consistent zoom behavior + const deltaDirection = scaleDelta > 1 ? -1 : 1; // Invert direction like wheel events + const deltaAmount = Math.abs(scaleDelta - 1) * Camera.PINCH_DELTA_MULTIPLIER; + + // Apply the same speed calculation as in handleWheelEvent + const pinchSpeed = Math.sign(deltaDirection) * clamp(deltaAmount, 1, 20); + const dScale = this.context.constants.camera.STEP * this.context.constants.camera.SPEED * pinchSpeed; + + const cameraScale = this.camera.getCameraScale(); + const smoothDScale = dScale * cameraScale; + + this.camera.zoom(canvasX, canvasY, cameraScale - smoothDScale); + } + + private handleTouchEndEvent = (event: TouchEvent) => { + // Prevent default browser behavior when ending multi-touch gestures + if (this.isPinching || event.changedTouches.length > 1) { + event.preventDefault(); + } + + this.resetPinchState(event); + }; + + /** + * Resets pinch gesture state when appropriate + * @param {TouchEvent} event - Touch event + * @returns {void} + */ + private resetPinchState(event: TouchEvent): void { + if (this.isPinching && event.touches.length < 2) { + this.isPinching = false; + this.lastPinchDistance = undefined; + } + } + + private startDragListening() { + dragListener(this.ownerDocument) + .on(EVENTS.DRAG_START, (event: MouseEvent | TouchEvent) => this.onDragStart(event)) + .on(EVENTS.DRAG_UPDATE, (event: MouseEvent | TouchEvent) => this.onDragUpdate(event)) + .on(EVENTS.DRAG_END, () => this.onDragEnd()); + } + + private onDragStart(event: MouseEvent | TouchEvent) { + // Don't start drag if we're in the middle of a pinch gesture + if (this.isPinching) { + return; + } this.lastDragEvent = event; } - private onDragUpdate(event: MouseEvent) { - if (!this.lastDragEvent) { + private onDragUpdate(event: MouseEvent | TouchEvent) { + if (!this.lastDragEvent || this.isPinching) { return; } - this.camera.move(event.pageX - this.lastDragEvent.pageX, event.pageY - this.lastDragEvent.pageY); + + const currentCoords = getEventPageCoordinates(event); + const lastCoords = getEventPageCoordinates(this.lastDragEvent); + + this.camera.move(currentCoords.pageX - lastCoords.pageX, currentCoords.pageY - lastCoords.pageY); this.lastDragEvent = event; } diff --git a/src/services/camera/CameraService.ts b/src/services/camera/CameraService.ts index f912d0c8..33c7628b 100644 --- a/src/services/camera/CameraService.ts +++ b/src/services/camera/CameraService.ts @@ -159,6 +159,7 @@ export class CameraService extends Emitter { const nextX = this.state.x + (dxInNextScale - dx) * normalizedScale; const nextY = this.state.y + (dyInNextScale - dy) * normalizedScale; + console.log("apply zoom", nextX, nextY, normalizedScale); this.set({ scale: normalizedScale, x: nextX, diff --git a/src/utils/functions/dragListener.ts b/src/utils/functions/dragListener.ts index b7102f61..20c2269b 100644 --- a/src/utils/functions/dragListener.ts +++ b/src/utils/functions/dragListener.ts @@ -1,56 +1,91 @@ import { Emitter } from "../Emitter"; import { EVENTS } from "../types/events"; +import { isTouchDevice } from "./touchUtils"; + +/** + * Creates a drag listener that works with both mouse and touch events + * @param {Document | HTMLDivElement | HTMLCanvasElement} document - Target element to listen for events + * @param {boolean} stopOnMouseLeave - Whether to stop dragging when mouse/touch leaves the element + * @returns {Emitter} Event emitter that emits DRAG_START, DRAG_UPDATE, and DRAG_END events + */ export function dragListener(document: Document | HTMLDivElement | HTMLCanvasElement, stopOnMouseLeave = false) { let started = false; let finished = false; const emitter = new Emitter(); - const mousemoveBinded = mousemove.bind(null, emitter); - const mouseupBinded = mouseup.bind(null, emitter); + const isTouch = isTouchDevice(); + + const moveBinded = move.bind(null, emitter); + const endBinded = end.bind(null, emitter); + + // Event names based on device type + const moveEventName = isTouch ? "touchmove" : "mousemove"; + const endEventName = isTouch ? "touchend" : "mouseup"; + const startEventName = isTouch ? "touchstart" : "mousedown"; + const leaveEventName = isTouch ? "touchend" : "mouseleave"; if (stopOnMouseLeave) { document.addEventListener( - "mouseleave", + leaveEventName, + (event) => { + if (started) { + endBinded(event); + } + finished = true; + document.removeEventListener(moveEventName, moveBinded); + }, + { once: true, capture: true } + ); + } + + // For touch devices, we also need to handle touchcancel + if (isTouch) { + document.addEventListener( + "touchend", (event) => { if (started) { - mouseupBinded(event); + endBinded(event); } finished = true; - document.removeEventListener("mousemove", mousemoveBinded); + document.removeEventListener(moveEventName, moveBinded); }, { once: true, capture: true } ); } document.addEventListener( - "mousemove", + moveEventName, (event) => { if (finished) { return; } + // Prevent default behavior for touch events to avoid scrolling + if (isTouch) { + event.preventDefault(); + } started = true; emitter.emit(EVENTS.DRAG_START, event); - document.addEventListener("mousemove", mousemoveBinded); + document.addEventListener(moveEventName, moveBinded); }, - { once: true, capture: true } + { once: true, capture: true, passive: false } ); document.addEventListener( - "mouseup", + endEventName, (event) => { if (started) { - mouseupBinded(event); + endBinded(event); } finished = true; - document.removeEventListener("mousemove", mousemoveBinded); + document.removeEventListener(moveEventName, moveBinded); }, { once: true, capture: true } ); document.addEventListener( - "mousedown", + startEventName, () => { - document.removeEventListener("mousemove", mousemoveBinded); + document.removeEventListener(moveEventName, moveBinded); }, { once: true, capture: true } ); @@ -58,11 +93,27 @@ export function dragListener(document: Document | HTMLDivElement | HTMLCanvasEle return emitter; } -function mousemove(emitter: Emitter, event: MouseEvent) { +/** + * Handles move events during drag operation + * @param {Emitter} emitter - Event emitter instance + * @param {MouseEvent | TouchEvent} event - Move event (mouse or touch) + * @returns {void} + */ +function move(emitter: Emitter, event: MouseEvent | TouchEvent): void { + // Prevent default behavior for touch events to avoid scrolling + if ("touches" in event) { + // event.preventDefault(); + } emitter.emit(EVENTS.DRAG_UPDATE, event); } -function mouseup(emitter: Emitter, event: MouseEvent) { +/** + * Handles end events for drag operation + * @param {Emitter} emitter - Event emitter instance + * @param {MouseEvent | TouchEvent} event - End event (mouse or touch) + * @returns {void} + */ +function end(emitter: Emitter, event: MouseEvent | TouchEvent): void { emitter.emit(EVENTS.DRAG_END, event); emitter.destroy(); } diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index 8fc30ef9..8035724f 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -3,6 +3,8 @@ import { ECanChangeBlockGeometry } from "../../store/settings"; import { EVENTS_DETAIL, SELECTION_EVENT_TYPES } from "../types/events"; import { Rect, TRect } from "../types/shapes"; +import { isTouchDevice } from "./touchUtils"; + export { parseClassNames } from "./classNames"; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -21,11 +23,11 @@ export function getXY(root: HTMLElement, event: Event | WheelEvent | MouseEvent) } export function getCoord(event: TouchEvent | MouseEvent, coord: string) { - const name = `page${coord.toUpperCase()}`; - - if (isTouchEvent(event)) { - return event.touches[0][name]; + if (isTouchEvent(event) && (event.touches.length > 0 || event.changedTouches.length > 0)) { + const name = `screen${coord.toUpperCase()}`; + return event.touches?.[0]?.[name] || event.changedTouches?.[0]?.[name]; } + const name = `page${coord.toUpperCase()}`; return event[name]; } @@ -192,6 +194,9 @@ function isTrackpadDetector() { let cleanStateTimer = setTimeout(() => {}, 0); return (e: WheelEvent, dpr: number = globalThis.devicePixelRatio || 1) => { + if (isTouchDevice()) { + return true; + } const normalizedDeltaY = e.deltaY * dpr; const normalizedDeltaX = e.deltaX * dpr; // deltaX in the trackpad scroll usually is not zero. @@ -273,3 +278,17 @@ export function computeCssVariable(name: string) { // Re-export scheduler utilities export { schedule, debounce, throttle } from "../utils/schedule"; + +// Export drag listener for mouse and touch events +export { dragListener } from "./dragListener"; + +// Export touch utilities +export { + isTouchDevice, + getEventPageCoordinates, + getEventClientCoordinates, + getTouchDistance, + getTouchCenter, + preventDefaultForMultiTouch, + isMultiTouch, +} from "./touchUtils"; diff --git a/src/utils/functions/touchUtils.ts b/src/utils/functions/touchUtils.ts new file mode 100644 index 00000000..7e70c4f2 --- /dev/null +++ b/src/utils/functions/touchUtils.ts @@ -0,0 +1,98 @@ +/** + * Touch utilities for handling touch events and gestures + */ + +/** + * Detects if the device supports touch events + * @returns {boolean} True if the device supports touch events + */ +export function isTouchDevice(): boolean { + return ( + navigator.maxTouchPoints > 0 || + typeof (window as unknown as { DocumentTouch?: unknown }).DocumentTouch !== "undefined" + ); +} + +/** + * Extracts page coordinates from mouse or touch event + * @param {MouseEvent | TouchEvent} event - The input event + * @returns {object} Page coordinates with pageX and pageY properties + */ +export function getEventPageCoordinates(event: MouseEvent | TouchEvent): { pageX: number; pageY: number } { + if ("touches" in event) { + const touch = event.touches[0] || event.changedTouches[0]; + return { pageX: touch.pageX, pageY: touch.pageY }; + } + return { pageX: event.pageX, pageY: event.pageY }; +} + +/** + * Extracts client coordinates from mouse or touch event + * @param {MouseEvent | TouchEvent} event - The input event + * @returns {object} Client coordinates with clientX and clientY properties + */ +export function getEventClientCoordinates(event: MouseEvent | TouchEvent): { clientX: number; clientY: number } { + if ("touches" in event) { + const touch = event.touches[0] || event.changedTouches[0]; + return { clientX: touch.clientX, clientY: touch.clientY }; + } + return { clientX: event.clientX, clientY: event.clientY }; +} + +/** + * Calculates distance between two touch points + * @param {TouchEvent} event - Touch event with at least 2 touches + * @returns {number} Distance between first two touch points + */ +export function getTouchDistance(event: TouchEvent): number { + if (event.touches.length < 2) return 0; + + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + + const dx = touch2.clientX - touch1.clientX; + const dy = touch2.clientY - touch1.clientY; + + return Math.sqrt(dx * dx + dy * dy); +} + +/** + * Calculates center point between two touches + * @param {TouchEvent} event - Touch event with at least 2 touches + * @returns {object} Center coordinates with x and y properties + */ +export function getTouchCenter(event: TouchEvent): { x: number; y: number } { + if (event.touches.length < 2) { + return { x: event.touches[0].clientX, y: event.touches[0].clientY }; + } + + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + + return { + x: (touch1.clientX + touch2.clientX) / 2, + y: (touch1.clientY + touch2.clientY) / 2, + }; +} + +/** + * Prevents default touch behavior for multi-touch events + * @param {TouchEvent} event - Touch event + * @param {number} minTouches - Minimum number of touches to prevent default (default: 2) + * @returns {void} + */ +export function preventDefaultForMultiTouch(event: TouchEvent, minTouches = 2): void { + if (event.touches.length >= minTouches) { + event.preventDefault(); + } +} + +/** + * Checks if the event is a multi-touch event + * @param {TouchEvent} event - Touch event + * @param {number} minTouches - Minimum number of touches to consider multi-touch (default: 2) + * @returns {boolean} True if it's a multi-touch event + */ +export function isMultiTouch(event: TouchEvent, minTouches = 2): boolean { + return event.touches.length >= minTouches; +}