From d8b5e6289271aa875fb11c99d057d98d790077ac Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 14 Aug 2026 15:33:08 +0700 Subject: [PATCH 1/4] feat: add pinch-to-zoom and pan functionality to Paper component - Implemented usePinchZoom hook for handling pinch and pan gestures. - Created tests for usePinchZoom to ensure correct behavior under various scenarios. - Developed touch-gesture recognition logic to support two-finger pinch and pan gestures. - Added integration tests for touch gestures in Paper component. - Introduced example story for demonstrating pinch-to-zoom and pan functionality in Storybook. --- .../src/components/paper/paper.types.ts | 19 ++ .../hooks/__tests__/use-pinch-zoom.test.tsx | 146 +++++++++ .../src/hooks/use-create-portal-paper.tsx | 3 + .../joint-react/src/hooks/use-pinch-zoom.ts | 88 ++++++ packages/joint-react/src/index.ts | 2 + packages/joint-react/src/internal.ts | 1 + .../presets/__tests__/paper-touch.test.tsx | 255 +++++++++++++++ packages/joint-react/src/presets/paper.css | 5 + packages/joint-react/src/presets/paper.ts | 151 ++++++++- .../utils/__tests__/touch-gestures.test.ts | 291 ++++++++++++++++++ .../joint-react/src/utils/touch-gestures.ts | 261 ++++++++++++++++ packages/joint-react/src/utils/wheel-guard.ts | 31 +- .../stories/examples/touch-pan-zoom/code.tsx | 75 +++++ .../stories/examples/touch-pan-zoom/story.tsx | 24 ++ 14 files changed, 1342 insertions(+), 10 deletions(-) create mode 100644 packages/joint-react/src/hooks/__tests__/use-pinch-zoom.test.tsx create mode 100644 packages/joint-react/src/hooks/use-pinch-zoom.ts create mode 100644 packages/joint-react/src/presets/__tests__/paper-touch.test.tsx create mode 100644 packages/joint-react/src/utils/__tests__/touch-gestures.test.ts create mode 100644 packages/joint-react/src/utils/touch-gestures.ts create mode 100644 packages/joint-react/stories/examples/touch-pan-zoom/code.tsx create mode 100644 packages/joint-react/stories/examples/touch-pan-zoom/story.tsx diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index 1dbddb76b9..e982a80b56 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -17,6 +17,7 @@ import type { CellVisibility } from '../../presets/cell-visibility'; import type { CellInteractivity } from '../../presets/cell-interactivity'; import type { LinkRouting } from '../../presets/link-routing'; import type { PaperEventHandlers } from '../../presets/paper-events'; +import type { PinchZoomBounds } from '../../hooks/use-pinch-zoom'; /** * Viewport transform accepted by the `` `transform` prop: either a CSS @@ -474,6 +475,24 @@ export interface PaperProps extends PaperSupportedOptions, PropsWithChildren, Pa */ readonly transform?: PaperTransform; + /** + * Built-in pinch-to-zoom: two-finger touch pinches and touchpad + * (`Ctrl`/`Cmd`+wheel) pinches zoom the canvas around the gesture point, + * and two-finger touch pans move it. Pass `{ min, max }` to tune the zoom + * bounds, or `false` to turn the behavior off (e.g. when the viewport is + * driven through the `transform` prop). + * + * Subscribing your own `onPaperPinch` / `onPaperPan` handler takes + * precedence — the built-in behavior yields automatically, no need to also + * pass `false`. + * @default true + * @example + * ```tsx + * + * ``` + */ + readonly zoomOnPinch?: boolean | PinchZoomBounds; + /** * Maximum pointer travel (in px) still treated as a click rather than a drag. * Moving farther than this between press and release suppresses the diff --git a/packages/joint-react/src/hooks/__tests__/use-pinch-zoom.test.tsx b/packages/joint-react/src/hooks/__tests__/use-pinch-zoom.test.tsx new file mode 100644 index 0000000000..33dce1ef46 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-pinch-zoom.test.tsx @@ -0,0 +1,146 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import { render, act, waitFor } from '@testing-library/react'; +import type { dia } from '@joint/core'; +import { GraphProvider } from '../../components'; +import { Paper } from '../../components/paper/paper'; +import type { PaperProps } from '../../components/paper/paper.types'; +import type { CellRecord } from '../../types/cell.types'; + +const EMPTY_CELLS: readonly CellRecord[] = []; + +function touchPanEvent(): dia.Event { + return { type: 'touchmove' } as unknown as dia.Event; +} + +function wheelPanEvent(): dia.Event { + return { type: 'wheel' } as unknown as dia.Event; +} + +// jsdom's mocked SVG matrix degenerates real transform math (all-zero CTM), so +// the paper's transform API is stubbed and the assertions target what the hook +// owns: clamping, yielding, and event filtering. +function stubPaperTransforms(paper: dia.Paper) { + const state = { scale: 1, tx: 0, ty: 0 }; + // The getter/setter overloads defeat direct mock typing — cast the whole + // implementation to the method type instead. + jest + .spyOn(paper, 'scale') + .mockImplementation( + (() => ({ sx: state.scale, sy: state.scale })) as unknown as dia.Paper['scale'] + ); + const scaleUniformAtPoint = jest + .spyOn(paper, 'scaleUniformAtPoint') + .mockImplementation((nextScale: number) => { + state.scale = nextScale; + return paper; + }); + const translate = jest.spyOn(paper, 'translate').mockImplementation( + ((tx?: number, ty?: number) => { + if (tx === undefined || ty === undefined) return { tx: state.tx, ty: state.ty }; + state.tx = tx; + state.ty = ty; + return paper; + }) as unknown as dia.Paper['translate'] + ); + return { state, scaleUniformAtPoint, translate }; +} + +async function renderPaper(props: Partial = {}) { + const paperRef: { current: dia.Paper | null } = { current: null }; + render( + + { + paperRef.current = paper; + }} + {...props} + /> + + ); + await waitFor(() => expect(paperRef.current).toBeTruthy()); + const paper = paperRef.current; + if (!paper) throw new Error('Paper was not created.'); + return { paper, ...stubPaperTransforms(paper) }; +} + +describe('usePinchZoom (Paper zoomOnPinch)', () => { + it('applies pinch samples as a clamped uniform scale by default', async () => { + const { paper, state, scaleUniformAtPoint } = await renderPaper(); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 20, 2)); + expect(scaleUniformAtPoint).toHaveBeenCalledWith(2, { x: 10, y: 20 }); + expect(state.scale).toBe(2); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 20, 2)); + expect(state.scale).toBe(4); + + // 4 × 2 = 8 exceeds the default upper bound of 5. + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 20, 2)); + expect(state.scale).toBe(5); + }); + + it('respects custom bounds', async () => { + const { paper, state } = await renderPaper({ zoomOnPinch: { min: 0.5, max: 2 } }); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 0, 0, 4)); + expect(state.scale).toBe(2); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 0, 0, 0.1)); + expect(state.scale).toBe(0.5); + }); + + it('is disabled with zoomOnPinch={false}', async () => { + const { paper, scaleUniformAtPoint } = await renderPaper({ zoomOnPinch: false }); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2)); + expect(scaleUniformAtPoint).not.toHaveBeenCalled(); + }); + + it('yields to an external paper:pinch subscriber', async () => { + const { paper, state, scaleUniformAtPoint } = await renderPaper(); + const external = jest.fn(); + paper.on('paper:pinch', external); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2)); + expect(external).toHaveBeenCalledTimes(1); + expect(scaleUniformAtPoint).not.toHaveBeenCalled(); // the external handler owns zooming + + // Once the external subscriber is gone, the built-in takes over again. + paper.off('paper:pinch', external); + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2)); + expect(state.scale).toBe(2); + }); + + it('yields to the onPaperPinch prop', async () => { + const onPaperPinch = jest.fn(); + const { paper, scaleUniformAtPoint } = await renderPaper({ onPaperPinch }); + + act(() => paper.trigger('paper:pinch', touchPanEvent(), 10, 10, 2)); + expect(onPaperPinch).toHaveBeenCalledTimes(1); + expect(scaleUniformAtPoint).not.toHaveBeenCalled(); + }); + + it('applies touch-sourced pan samples as a translation', async () => { + const { paper, state } = await renderPaper(); + + act(() => paper.trigger('paper:pan', touchPanEvent(), 10, 20)); + expect(state).toMatchObject({ tx: -10, ty: -20 }); + }); + + it('ignores wheel-sourced pan events', async () => { + const { paper, translate } = await renderPaper(); + + act(() => paper.trigger('paper:pan', wheelPanEvent(), 10, 20)); + expect(translate).not.toHaveBeenCalled(); + }); + + it('yields to an external paper:pan subscriber', async () => { + const { paper, translate } = await renderPaper(); + const external = jest.fn(); + paper.on('paper:pan', external); + + act(() => paper.trigger('paper:pan', touchPanEvent(), 10, 20)); + expect(external).toHaveBeenCalledTimes(1); + expect(translate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/joint-react/src/hooks/use-create-portal-paper.tsx b/packages/joint-react/src/hooks/use-create-portal-paper.tsx index e4f04445db..abd565d7c7 100644 --- a/packages/joint-react/src/hooks/use-create-portal-paper.tsx +++ b/packages/joint-react/src/hooks/use-create-portal-paper.tsx @@ -48,6 +48,7 @@ import { useAreElementsMeasured } from './use-are-elements-measured'; import { LINK_MODEL_TYPE } from '../mvc/link-model'; import { subscribeToPaperEvents } from './use-on-paper-events'; import { useOnEvents } from './use-on-events'; +import { usePinchZoom } from './use-pinch-zoom'; import type { CellId } from '../types/cell.types'; import { extractEventsFromPaperProps } from '../presets/paper-events'; @@ -193,6 +194,7 @@ export function useCreatePortalPaper( transform, portalSelector, linkRouting, + zoomOnPinch, options: escapeHatchOptions, // These are React host props and must not be forwarded to dia.Paper options. // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -310,6 +312,7 @@ export function useCreatePortalPaper( const eventHandlers = useMemo(() => extractEventsFromPaperProps(paperOptions), [paperOptions]); useOnEvents(paperStore, eventHandlers, subscribeToPaperEvents); + usePinchZoom(paperStore, zoomOnPinch); useLayoutEffect(() => { const hostElementForCreation = nodeRef?.current; diff --git a/packages/joint-react/src/hooks/use-pinch-zoom.ts b/packages/joint-react/src/hooks/use-pinch-zoom.ts new file mode 100644 index 0000000000..fb5a22cac7 --- /dev/null +++ b/packages/joint-react/src/hooks/use-pinch-zoom.ts @@ -0,0 +1,88 @@ +import { mvc, type dia } from '@joint/core'; +import { useEffect } from 'react'; +import type { PaperStore } from '../store/paper-store'; + +/** + * Zoom limits for the built-in pinch-zoom behavior of ``. + * @group Types + */ +export interface PinchZoomBounds { + /** + * Lower zoom bound. + * @default 0.2 + */ + readonly min?: number; + /** + * Upper zoom bound. + * @default 5 + */ + readonly max?: number; +} + +const DEFAULT_MIN_ZOOM = 0.2; +const DEFAULT_MAX_ZOOM = 5; + +/** + * Count subscribers of a paper event — the same internal `_events` + * introspection joint-core uses to gate its ctrl+wheel pinch handling. + * @param paper - The paper whose subscriptions are inspected. + * @param eventName - Paper event name. + * @returns Number of handlers currently subscribed. + */ +function countSubscribers(paper: dia.Paper, eventName: string): number { + const events = (paper as unknown as { _events?: Record })._events; + return events?.[eventName]?.length ?? 0; +} + +function isTouchSourced(event: dia.Event): boolean { + const type = (event as { type?: string }).type ?? ''; + return type.startsWith('touch'); +} + +/** + * Built-in pinch-zoom behavior for ``: applies `paper:pinch` samples as + * a clamped `scaleUniformAtPoint` around the gesture point, and touch-sourced + * `paper:pan` samples as a translation — so pinch-to-zoom and two-finger pan + * work out of the box on a touchscreen and a touchpad (ctrl+wheel). + * + * Self-yielding: whenever any other subscriber listens to the same event (an + * `onPaperPinch` / `onPaperPan` prop, a scroller's interactions), that + * subscriber owns the gesture and the built-in does nothing. Checked per + * event, so handlers subscribed at any time are honored. + * @param paperStore - Store owning the paper, or nullish before creation. + * @param zoomOnPinch - The `zoomOnPinch` prop: `false` disables, an object tunes the bounds. + * @internal + */ +export function usePinchZoom( + paperStore: PaperStore | null | undefined, + zoomOnPinch: boolean | PinchZoomBounds | undefined +): void { + const isEnabled = zoomOnPinch !== false; + const bounds = typeof zoomOnPinch === 'object' ? zoomOnPinch : undefined; + const minZoom = bounds?.min ?? DEFAULT_MIN_ZOOM; + const maxZoom = bounds?.max ?? DEFAULT_MAX_ZOOM; + const paper = paperStore?.paper; + + useEffect(() => { + if (!paper || !isEnabled) return; + const onPinch = (_event: dia.Event, x: number, y: number, scale: number) => { + if (countSubscribers(paper, 'paper:pinch') > 1) return; + const currentScale = paper.scale().sx; + const nextScale = Math.min(maxZoom, Math.max(minZoom, currentScale * scale)); + if (nextScale === currentScale) return; + paper.scaleUniformAtPoint(nextScale, { x, y }); + }; + const onPan = (event: dia.Event, deltaX: number, deltaY: number) => { + // Touch gestures only: wheel emits `paper:pan` for every plain scroll, + // and out-of-the-box wheel panning is not part of this behavior. + if (!isTouchSourced(event)) return; + if (countSubscribers(paper, 'paper:pan') > 1) return; + const { tx, ty } = paper.translate(); + paper.translate(tx - deltaX, ty - deltaY); + }; + const controller = new mvc.Listener(); + controller.listenTo(paper, 'paper:pinch', onPinch); + controller.listenTo(paper, 'paper:pan', onPan); + return () => controller.stopListening(); + }, [paper, isEnabled, minZoom, maxZoom]); +} diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 9ae4148f34..d11aee8076 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -32,6 +32,8 @@ export type { DefaultLinkParams, } from './components/paper/paper.types'; /** @group Types */ +export type { PinchZoomBounds } from './hooks/use-pinch-zoom'; +/** @group Types */ export type { PortalHostCell, PortalSelector, PortalSelectorParams } from './mvc/paper.types'; /** @group Types */ export type { diff --git a/packages/joint-react/src/internal.ts b/packages/joint-react/src/internal.ts index 99fb9fb8d5..adb328c574 100644 --- a/packages/joint-react/src/internal.ts +++ b/packages/joint-react/src/internal.ts @@ -67,6 +67,7 @@ export { assignOptions, pickValues, makeOptions } from './utils/object-utilities export { resolvePaper, resolvePaperId, isPaperTarget } from './utils/resolve-paper-target'; export { isRecord } from './utils/is'; export { wheelGuard, SCROLLABLE_ATTRIBUTE } from './utils/wheel-guard'; +export { isMultiTouchEvent } from './utils/touch-gestures'; // Constants export { ELEMENT_MODEL_TYPE, PORTAL_SELECTOR } from './mvc/element-model'; diff --git a/packages/joint-react/src/presets/__tests__/paper-touch.test.tsx b/packages/joint-react/src/presets/__tests__/paper-touch.test.tsx new file mode 100644 index 0000000000..37f5902878 --- /dev/null +++ b/packages/joint-react/src/presets/__tests__/paper-touch.test.tsx @@ -0,0 +1,255 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import { render, waitFor } from '@testing-library/react'; +import type { CSSProperties } from 'react'; +import type { dia } from '@joint/core'; +import { GraphProvider } from '../../components'; +import { Paper } from '../../components/paper/paper'; +import type { CellRecord } from '../../types/cell.types'; + +const PAPER_STYLE: CSSProperties = { width: 400, height: 400 }; + +// Integration coverage for the touch-gesture layer wired in presets/paper.ts: +// real DOM dispatches on a rendered , asserting the emitted +// `paper:pinch` / `paper:pan` events and the drag-cancel behavior. Geometry +// going through the mocked SVG CTM degenerates in jsdom, so local x/y args are +// not asserted — the scale ratio and pan deltas are pure client-space math. + +beforeEach(() => { + // jsdom does not implement elementFromPoint; core's `getEventTarget` calls + // it for captured-pointer / touch events. `null` = nothing under the point. + Object.defineProperty(document, 'elementFromPoint', { + value: () => null, + writable: true, + configurable: true, + }); +}); + +const initialCells: readonly CellRecord[] = [ + { + id: 'n1', + type: 'element', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + data: {}, + }, +]; + +interface TouchStub { + readonly x: number; + readonly y: number; + readonly target?: Element; +} + +function dispatchTouch(target: Element, type: string, touches: readonly TouchStub[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + const touchList = touches.map((touch) => ({ + clientX: touch.x, + clientY: touch.y, + target: touch.target ?? target, + })); + Object.assign(event, { + touches: touchList, + changedTouches: touchList, + clientX: touches[0]?.x ?? 0, + clientY: touches[0]?.y ?? 0, + }); + target.dispatchEvent(event); + return event; +} + +async function renderTouchPaper() { + const paperRef: { current: dia.Paper | null } = { current: null }; + const { container, unmount } = render( + + { + paperRef.current = paper; + }} + style={PAPER_STYLE} + /> + + ); + await waitFor(() => expect(container.querySelector('.joint-cell')).toBeTruthy()); + const paper = paperRef.current; + if (!paper) throw new Error('Paper was not created.'); + const cellNode = container.querySelector('.joint-cell'); + if (!cellNode) throw new Error('Cell node not rendered.'); + return { paper, cellNode, container, unmount }; +} + +describe('paper preset touch gestures', () => { + it('emits paper:pinch with the relative scale ratio for a two-finger pinch', async () => { + const { paper, cellNode } = await renderTouchPaper(); + const onPinch = jest.fn(); + paper.on('paper:pinch', onPinch); + + const start = dispatchTouch(cellNode, 'touchstart', [ + { x: 100, y: 100 }, + { x: 200, y: 100 }, + ]); + // Gesture start is announced immediately with a scale-1 pinch and the + // second finger's touchstart is consumed (no browser pinch-zoom). + expect(onPinch).toHaveBeenCalledTimes(1); + expect(onPinch.mock.calls[0][3]).toBe(1); + expect(start.defaultPrevented).toBe(true); + + // Fingers spread 100 → 200 apart: relative scale ratio 2 after the + // animation-frame flush. + dispatchTouch(cellNode, 'touchmove', [ + { x: 50, y: 100 }, + { x: 250, y: 100 }, + ]); + await waitFor(() => expect(onPinch).toHaveBeenCalledTimes(2)); + expect(onPinch.mock.calls[1][3]).toBe(2); + }); + + it('emits paper:pan with wheel-convention deltas for a two-finger pan', async () => { + const { paper, cellNode } = await renderTouchPaper(); + const onPan = jest.fn(); + paper.on('paper:pan', onPan); + // Subscribe pinch too so the built-in zoom yields (not under test here). + paper.on('paper:pinch', jest.fn()); + + dispatchTouch(cellNode, 'touchstart', [ + { x: 100, y: 100 }, + { x: 200, y: 100 }, + ]); + // Both fingers move right by 30 and down by 10 — content follows the + // fingers, which in wheel convention is a negative delta. + dispatchTouch(cellNode, 'touchmove', [ + { x: 130, y: 110 }, + { x: 230, y: 110 }, + ]); + await waitFor(() => expect(onPan).toHaveBeenCalledTimes(1)); + expect(onPan.mock.calls[0][1]).toBe(-30); + expect(onPan.mock.calls[0][2]).toBe(-10); + }); + + it('stops an in-flight single-finger drag when the second finger lands, without a click', async () => { + const { paper, cellNode } = await renderTouchPaper(); + const onCellPointerDown = jest.fn(); + const onBlankPointerUp = jest.fn(); + const onCellPointerClick = jest.fn(); + const onBlankPointerClick = jest.fn(); + paper.on('cell:pointerdown', onCellPointerDown); + paper.on('blank:pointerup', onBlankPointerUp); + paper.on('cell:pointerclick', onCellPointerClick); + paper.on('blank:pointerclick', onBlankPointerClick); + paper.on('paper:pinch', jest.fn()); + + // Finger 1 starts a regular cell drag through core's touchstart pipeline. + dispatchTouch(cellNode, 'touchstart', [{ x: 50, y: 50 }]); + expect(onCellPointerDown).toHaveBeenCalledTimes(1); + + // Finger 2 promotes the sequence to a pinch. Core still processes its + // touchstart (official pattern), and the gesture detector neutralizes it: + // `preventDefaultInteraction` stops the new cell drag from starting and + // `paper.pointerup` ends the single-pointer interaction (the finger-1 + // drag's document events undelegate). + dispatchTouch(cellNode, 'touchstart', [ + { x: 50, y: 50 }, + { x: 150, y: 50 }, + ]); + expect(onCellPointerDown).toHaveBeenCalledTimes(2); // official: finger 2 is seen, then neutralized + expect(onBlankPointerUp).toHaveBeenCalledTimes(1); // the neutralizing pointerup + // A two-finger press must not click / select the pressed cell. + expect(onCellPointerClick).not.toHaveBeenCalled(); + expect(onBlankPointerClick).not.toHaveBeenCalled(); + }); + + it('neutralizes the re-delegated drag on the first touch-driven pointermove', async () => { + const { paper, cellNode } = await renderTouchPaper(); + const onCellPointerMove = jest.fn(); + const onCellPointerUp = jest.fn(); + const onCellPointerClick = jest.fn(); + paper.on('cell:pointermove', onCellPointerMove); + paper.on('cell:pointerup', onCellPointerUp); + paper.on('cell:pointerclick', onCellPointerClick); + paper.on('paper:pinch', jest.fn()); + + dispatchTouch(cellNode, 'touchstart', [{ x: 50, y: 50 }]); + // Finger 2: core re-delegates drag events after the neutralization (it + // runs inside `pointerdown`, before `delegateDragEvents`). + dispatchTouch(cellNode, 'touchstart', [ + { x: 50, y: 50 }, + { x: 150, y: 50 }, + ]); + + // Drag moves arrive as pointer events under this preset. The first one is + // caught by the pointermove leg of the detector and ends the drag — at + // most one garbled move leaks (same as the official demo). + const pointerMove = new Event('pointermove', { bubbles: true }); + Object.assign(pointerMove, { pointerType: 'touch', clientX: 60, clientY: 60 }); + document.dispatchEvent(pointerMove); + expect(onCellPointerMove).toHaveBeenCalledTimes(1); + expect(onCellPointerUp).toHaveBeenCalledTimes(1); + // The neutralizing pointerup must not synthesize a click. + expect(onCellPointerClick).not.toHaveBeenCalled(); + + // The drag is gone — further moves reach nothing. + document.dispatchEvent(pointerMove); + expect(onCellPointerMove).toHaveBeenCalledTimes(1); + }); + + it('keeps single-finger touches on core untouched', async () => { + const { paper, cellNode } = await renderTouchPaper(); + const onCellPointerDown = jest.fn(); + const onPinch = jest.fn(); + paper.on('cell:pointerdown', onCellPointerDown); + paper.on('paper:pinch', onPinch); + + const start = dispatchTouch(cellNode, 'touchstart', [{ x: 50, y: 50 }]); + expect(onCellPointerDown).toHaveBeenCalledTimes(1); + expect(onPinch).not.toHaveBeenCalled(); + // Core's own preventDefault policy applies — the gesture layer didn't veto + // propagation (the event reached core's delegated handler at all). + expect(start.defaultPrevented).toBe(true); // preventDefaultViewAction default + }); + + it('leaves gestures on scrollable regions to the region', async () => { + const { paper } = await renderTouchPaper(); + const onPinch = jest.fn(); + paper.on('paper:pinch', onPinch); + + // A scrollable list inside the paper (same opt-out the wheel guard uses). + const scrollable = document.createElement('div'); + scrollable.dataset.jjScrollable = ''; + Object.defineProperty(scrollable, 'scrollHeight', { value: 500 }); + Object.defineProperty(scrollable, 'clientHeight', { value: 100 }); + paper.el.append(scrollable); + + const start = dispatchTouch(scrollable, 'touchstart', [ + { x: 10, y: 10 }, + { x: 40, y: 40 }, + ]); + expect(onPinch).not.toHaveBeenCalled(); + expect(start.defaultPrevented).toBe(false); // the region keeps native scrolling + }); + + it('drains the remaining finger after the gesture ends (no phantom interactions)', async () => { + const { paper, cellNode } = await renderTouchPaper(); + const onCellPointerDown = jest.fn(); + paper.on('cell:pointerdown', onCellPointerDown); + paper.on('paper:pinch', jest.fn()); + + // Both fingers land in a single touchstart: core sees one pointerdown + // (official pattern) which the detector immediately neutralizes. + dispatchTouch(cellNode, 'touchstart', [ + { x: 100, y: 100 }, + { x: 200, y: 100 }, + ]); + expect(onCellPointerDown).toHaveBeenCalledTimes(1); + + // One finger lifts — the gesture ends, the leftover finger drains: its + // moves stay browser-prevented and start nothing on the paper. + dispatchTouch(cellNode, 'touchend', [{ x: 100, y: 100 }]); + const drainMove = dispatchTouch(cellNode, 'touchmove', [{ x: 120, y: 120 }]); + expect(drainMove.defaultPrevented).toBe(true); + expect(onCellPointerDown).toHaveBeenCalledTimes(1); + + // All fingers up — the next single-finger touch flows to core again. + dispatchTouch(cellNode, 'touchend', []); + dispatchTouch(cellNode, 'touchstart', [{ x: 50, y: 50 }]); + expect(onCellPointerDown).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/joint-react/src/presets/paper.css b/packages/joint-react/src/presets/paper.css index 32223983fd..e0f3a52ed0 100644 --- a/packages/joint-react/src/presets/paper.css +++ b/packages/joint-react/src/presets/paper.css @@ -26,6 +26,11 @@ .jj-paper { background-color: var(--jj-paper-color); height: stretch; + /* Disable the browser's double-tap zoom so fast taps stay app gestures + (`dbltap`). Pan / pinch suppression is handled selectively in JS (see + utils/touch-gestures) so `[data-jj-scrollable]` regions keep native + touch scrolling — `touch-action: none` here would take it from them. */ + touch-action: manipulation; } /* ── Paper Interactions ─────────────────────────────────────────────────────── */ diff --git a/packages/joint-react/src/presets/paper.ts b/packages/joint-react/src/presets/paper.ts index a64dd1a758..94b2c7bbd0 100644 --- a/packages/joint-react/src/presets/paper.ts +++ b/packages/joint-react/src/presets/paper.ts @@ -3,7 +3,8 @@ import { measureNode } from './measure-node'; import { linkRoutingStraight } from './link-routing'; import { LinkView } from './link-view'; import { MagnetHighlighter, MAGNET_HIGHLIGHTER_NAME } from './magnet-highlighter'; -import { wheelGuard } from '../utils/wheel-guard'; +import { isInsideScrollableRegion, wheelGuard } from '../utils/wheel-guard'; +import { isMultiTouchEvent, TouchGestureRecognizer } from '../utils/touch-gestures'; // --------------------------------------------------------------------------- // PointerEvents migration @@ -23,6 +24,7 @@ type ProtectedPaperPrototype = { readonly pointerup: (event: dia.Event) => void; readonly startListening: () => void; readonly guard: (event: dia.Event, view: dia.CellView) => boolean; + readonly onRemove: () => void; }; const protectedProto = dia.Paper.prototype as unknown as ProtectedPaperPrototype; @@ -45,6 +47,143 @@ const DEFAULT_CLICK_THRESHOLD = 5; const DEFAULT_GRID_SIZE = 10; const DEFAULT_SNAP_RADIUS = 15; +// --------------------------------------------------------------------------- +// Touch gestures (pinch-to-zoom / two-finger pan) +// --------------------------------------------------------------------------- +// Joint-core models multi-touch nowhere: a second `touchstart` re-enters +// `pointerdown` and garbles the drag, and `paper:pinch` fires only from the +// touchpad's ctrl+wheel. This layer follows the structure of the official +// JointJS+ touch demo: single-pointer interactions are neutralized via the +// sanctioned APIs when a second finger lands (`preventDefaultInteraction` + +// `paper.pointerup`, see `stopInteractionIfGestureDetected`), and an +// in-library recognizer (the demo uses interact.js) turns the two-finger +// stream into the very same `paper:pinch` / `paper:pan` events core fires for +// touchpad gestures — so wheel and touch share one consumer pipeline. + +/** Per-paper touch-gesture teardown, run from `onRemove`. */ +const touchGestureCleanup = new WeakMap void>(); + +/** Access to the paper's own `pointerup` dispatch (not part of the public typings). */ +type PaperPointerHandlers = { readonly pointerup: (event: dia.Event) => void }; + +/** + * The official JointJS touch pattern (mirrors the JointJS+ touch demo): when a + * second finger lands during a single-pointer interaction, the interaction is + * neutralized through the sanctioned APIs — `cellView.preventDefaultInteraction` + * keeps a cell drag from starting, and `paper.pointerup` ends whatever + * single-pointer interaction had already started (drag document events + * undelegate, pointer capture releases). Wired to `pointerdown` AND + * `pointermove` because the paper re-delegates drag events on every + * `pointerdown` — the first drag move after that re-delegation is caught here. + * @param paper - The paper whose interaction should stop. + * @param recognizer - The gesture recognizer (its phase identifies touch-driven pointer moves). + * @param cellView - The view under the pointer, or `null` for a blank interaction. + * @param event - The paper-delivered event. + */ +function stopInteractionIfGestureDetected( + paper: dia.Paper, + recognizer: TouchGestureRecognizer, + cellView: dia.CellView | null, + event: dia.Event +): void { + // With this preset's PointerEvents `documentEvents`, drag moves arrive as + // `pointermove` events that carry no `touches` list — the recognizer's + // active phase stands in for the official `touches.length` check there. + const isTouchDrivenPointerMove = + event.type === 'pointermove' && + (event.originalEvent as Partial | undefined)?.pointerType === 'touch' && + recognizer.isActive(); + if (!isMultiTouchEvent(event) && !isTouchDrivenPointerMove) return; + if (cellView) cellView.preventDefaultInteraction(event); + // The neutralizing pointerup must not synthesize a `pointerclick` for the + // second finger — `pointerup` skips the click for propagation-stopped events. + event.stopPropagation(); + (paper as unknown as PaperPointerHandlers).pointerup(event); +} + +/** + * Safari's proprietary gesture pipeline — prevented as belt-and-braces next to + * the touch-level preventDefault (a no-op everywhere else). + * @param event - The `gesturestart` event. + */ +const preventNativeGesture = (event: Event) => event.preventDefault(); + +/** + * Attach the two-finger gesture layer to a paper, following the official + * JointJS touch demo structure: (1) `stopInteractionIfGestureDetected` + * subscribed to the cell / blank `pointerdown` + `pointermove` events, and + * (2) a gesture recognizer on `paper.el` (the in-library replacement for the + * demo's interact.js `gesturable`) that re-emits two-finger pinch / pan as the + * very same `paper:pinch` / `paper:pan` events core fires for touchpad + * (ctrl+wheel) gestures. Listeners are capture-phase and `passive: false` so + * `preventDefault` reliably stops the browser's own pinch-zoom / scroll. + * Gestures starting on a natively scrollable region (`[data-jj-scrollable]` / + * `