From 2e857df8069095357b3d1f32ce83db70406c1d5d Mon Sep 17 00:00:00 2001 From: f0rdream <14049186+f0rdream@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:09:53 +0800 Subject: [PATCH 1/3] feat: add gesture-runtime --- .changeset/olive-bugs-prove.md | 5 + biome.jsonc | 1 + eslint.config.js | 3 + packages/lynx/gesture-runtime/CHANGELOG.md | 0 packages/lynx/gesture-runtime/README.md | 25 ++ .../__test__/gesture-callback.test.tsx | 323 ++++++++++++++++ .../__test__/gesture-clone.test.ts | 250 +++++++++++++ .../gesture-composition-advanced.test.ts | 274 ++++++++++++++ .../__test__/gesture-config.test.ts | 221 +++++++++++ .../__test__/gesture-plain.test.ts | 200 ++++++++++ .../__test__/gesture-react.test.tsx | 348 ++++++++++++++++++ .../__test__/gesture-relations.test.tsx | 191 ++++++++++ .../__test__/is-worklet-object.test.ts | 86 +++++ .../__test__/type-safety.test.ts | 98 +++++ .../__test__/utils/callback.ts | 64 ++++ .../gesture-runtime/__test__/utils/setup.ts | 3 + packages/lynx/gesture-runtime/package.json | 38 ++ packages/lynx/gesture-runtime/rslib.config.ts | 17 + .../lynx/gesture-runtime/src/baseGesture.ts | 303 +++++++++++++++ .../lynx/gesture-runtime/src/composition.ts | 170 +++++++++ .../src/defaultScrollGesture.ts | 24 ++ .../gesture-runtime/src/env_types/global.d.ts | 30 ++ .../lynx/gesture-runtime/src/flingGesture.ts | 22 ++ .../gesture-runtime/src/gestureInterface.ts | 302 +++++++++++++++ packages/lynx/gesture-runtime/src/index.ts | 48 +++ .../gesture-runtime/src/longPressGesture.ts | 31 ++ .../lynx/gesture-runtime/src/panGesture.ts | 49 +++ .../lynx/gesture-runtime/src/tapGesture.ts | 33 ++ .../lynx/gesture-runtime/src/useGesture.ts | 36 ++ .../lynx/gesture-runtime/src/utils/const.ts | 6 + .../src/utils/isWorkletObject.ts | 14 + .../src/utils/removeUndefined.ts | 10 + .../lynx/gesture-runtime/tsconfig.build.json | 20 + packages/lynx/gesture-runtime/tsconfig.json | 7 + .../lynx/gesture-runtime/tsconfig.test.json | 7 + .../lynx/gesture-runtime/vitest.config.ts | 17 + packages/lynx/tsconfig.json | 11 + pnpm-lock.yaml | 29 +- tsconfig.json | 3 + 39 files changed, 3312 insertions(+), 7 deletions(-) create mode 100644 .changeset/olive-bugs-prove.md create mode 100644 packages/lynx/gesture-runtime/CHANGELOG.md create mode 100644 packages/lynx/gesture-runtime/README.md create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-config.test.ts create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx create mode 100644 packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts create mode 100644 packages/lynx/gesture-runtime/__test__/type-safety.test.ts create mode 100644 packages/lynx/gesture-runtime/__test__/utils/callback.ts create mode 100644 packages/lynx/gesture-runtime/__test__/utils/setup.ts create mode 100644 packages/lynx/gesture-runtime/package.json create mode 100644 packages/lynx/gesture-runtime/rslib.config.ts create mode 100644 packages/lynx/gesture-runtime/src/baseGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/composition.ts create mode 100644 packages/lynx/gesture-runtime/src/defaultScrollGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/env_types/global.d.ts create mode 100644 packages/lynx/gesture-runtime/src/flingGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/gestureInterface.ts create mode 100644 packages/lynx/gesture-runtime/src/index.ts create mode 100644 packages/lynx/gesture-runtime/src/longPressGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/panGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/tapGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/useGesture.ts create mode 100644 packages/lynx/gesture-runtime/src/utils/const.ts create mode 100644 packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts create mode 100644 packages/lynx/gesture-runtime/src/utils/removeUndefined.ts create mode 100644 packages/lynx/gesture-runtime/tsconfig.build.json create mode 100644 packages/lynx/gesture-runtime/tsconfig.json create mode 100644 packages/lynx/gesture-runtime/tsconfig.test.json create mode 100644 packages/lynx/gesture-runtime/vitest.config.ts create mode 100644 packages/lynx/tsconfig.json diff --git a/.changeset/olive-bugs-prove.md b/.changeset/olive-bugs-prove.md new file mode 100644 index 0000000000..3264f1bd71 --- /dev/null +++ b/.changeset/olive-bugs-prove.md @@ -0,0 +1,5 @@ +--- +'@lynx-js/gesture-runtime': minor +--- + +Initialize `'@lynx-js/gesture-runtime` diff --git a/biome.jsonc b/biome.jsonc index eb00fcb238..844cb06796 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -53,6 +53,7 @@ "packages/testing-library/**", "packages/react/testing-library/**", + "packages/lynx/gesture-runtime/__test__/**" ], "rules": { // We are migrating from ESLint to Biome diff --git a/eslint.config.js b/eslint.config.js index 76db4d4e5c..ce07104a1f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -97,6 +97,9 @@ export default tseslint.config( // testing-library 'packages/testing-library/**', 'packages/react/testing-library/**', + + // gesture-runtime-testing + 'packages/lynx/gesture-runtime/__test__/**', ], }, js.configs.recommended, diff --git a/packages/lynx/gesture-runtime/CHANGELOG.md b/packages/lynx/gesture-runtime/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/lynx/gesture-runtime/README.md b/packages/lynx/gesture-runtime/README.md new file mode 100644 index 0000000000..02eb265321 --- /dev/null +++ b/packages/lynx/gesture-runtime/README.md @@ -0,0 +1,25 @@ +# Lynx Gesture Runtime + +`@lynx-js/gesture-runtime` provides typed gesture primitives and simple composition utilities for Lynx. + +## Install + +- `pnpm add @lynx-js/gesture-runtime @lynx-js/react` + +## Usage + +```tsx +import { useGesture, PanGesture } from '@lynx-js/gesture-runtime'; + +export default function Example() { + const pan = useGesture(PanGesture).onUpdate((event, stateManager) => { + 'main thread'; + stateManager.active(); + }); + return ; +} +``` + +## Docs + +- See the dedicated guide website for full documentation and API details. diff --git a/packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx new file mode 100644 index 0000000000..f33aba0d37 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx @@ -0,0 +1,323 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; + +import { useRef } from '@lynx-js/react'; +import { act, render } from '@lynx-js/react/testing-library'; + +import { PanGesture } from '../src/index.js'; +import { useGesture } from '../src/useGesture.js'; +import { + MockGestureManager, + genEventObj, + triggerGestureCallback, +} from './utils/callback.js'; + +describe('gestures mt', () => { + let spySetGesture: MockInstance; + let _gestureNode: any; + + beforeEach(() => { + spySetGesture = vi.spyOn( + lynxTestingEnv.mainThread.globalThis, + '__SetGestureDetector', + ).mockImplementation(function( + node: any, + id: number, + type: number, + config: any, + relationMap: Record, + ) { + node.gesture = { + id, + type, + config, + relationMap, + }; + + _gestureNode = node; + }); + }); + + afterEach(() => { + spySetGesture.mockRestore(); + _gestureNode = null; + }); + + test('bind gestures should call papi correctly', async () => { + let _panGesture; + const mockGestureManager = new MockGestureManager(); + + const App = () => { + const panGesture = useGesture(PanGesture); + panGesture.onBegin((event, stateManager) => { + 'main thread'; + globalThis._eventObj = event; + }); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + await act(() => { + const eventObj = genEventObj(_gestureNode, { + params: { x: 0, y: 1 }, + }); + + triggerGestureCallback( + _gestureNode, + 'onBegin', + eventObj, + mockGestureManager, + ); + }); + + expect(globalThis._eventObj.params.x).toBe(0); + expect(globalThis._eventObj.params.y).toBe(1); + + delete globalThis._eventObj; + }); + + test('stateManager.active should call __SetGestureDetector correctly', async () => { + let _panGesture; + const mockGestureManager = new MockGestureManager(); + + const App = () => { + const panGesture = useGesture(PanGesture); + panGesture.onBegin((event, stateManager) => { + 'main thread'; + stateManager.active(); + }); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + await act(() => { + const eventObj = genEventObj(_gestureNode, { + params: { x: 0, y: 1 }, + }); + + triggerGestureCallback( + _gestureNode, + 'onBegin', + eventObj, + mockGestureManager, + ); + }); + + expect(mockGestureManager.__SetGestureState).toBeCalledWith( + _gestureNode, + _gestureNode.gesture.id, + 1, + ); + }); + + test('stateManager.fail should call __SetGestureDetector correctly', async () => { + let _panGesture; + const mockGestureManager = new MockGestureManager(); + + const App = () => { + const panGesture = useGesture(PanGesture); + panGesture.onBegin((event, stateManager) => { + 'main thread'; + stateManager.fail(); + }); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + await act(() => { + const eventObj = genEventObj(_gestureNode, { + params: { x: 0, y: 1 }, + }); + + triggerGestureCallback( + _gestureNode, + 'onBegin', + eventObj, + mockGestureManager, + ); + }); + + expect(mockGestureManager.__SetGestureState).toBeCalledWith( + _gestureNode, + _gestureNode.gesture.id, + 2, + ); + }); + + test('stateManager.end should call __SetGestureDetector correctly', async () => { + let _panGesture; + const mockGestureManager = new MockGestureManager(); + + const App = () => { + const panGesture = useGesture(PanGesture); + panGesture.onBegin((event, stateManager) => { + 'main thread'; + stateManager.end(); + }); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + await act(() => { + const eventObj = genEventObj(_gestureNode, { + params: { x: 0, y: 1 }, + }); + + triggerGestureCallback( + _gestureNode, + 'onBegin', + eventObj, + mockGestureManager, + ); + }); + + expect(mockGestureManager.__SetGestureState).toBeCalledWith( + _gestureNode, + _gestureNode.gesture.id, + 3, + ); + }); + + test('stateManager.consumeGesture should call __ConsumeGesture correctly', async () => { + let _panGesture; + const mockGestureManager = new MockGestureManager(); + + const App = () => { + const panGesture = useGesture(PanGesture); + panGesture.onBegin((event, stateManager) => { + 'main thread'; + stateManager.consumeGesture(true); + }); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + await act(() => { + const eventObj = genEventObj(_gestureNode, { + params: { x: 0, y: 1 }, + }); + + triggerGestureCallback( + _gestureNode, + 'onBegin', + eventObj, + mockGestureManager, + ); + }); + + expect(mockGestureManager.__ConsumeGesture).toBeCalledWith( + _gestureNode, + _gestureNode.gesture.id, + { consume: true, inner: true }, + ); + }); + + test('stateManager.interceptGesture should call __ConsumeGesture correctly', async () => { + let _panGesture; + const mockGestureManager = new MockGestureManager(); + + const App = () => { + const panGesture = useGesture(PanGesture); + panGesture.onBegin((event, stateManager) => { + 'main thread'; + stateManager.interceptGesture(true); + }); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + await act(() => { + const eventObj = genEventObj(_gestureNode, { + params: { x: 0, y: 1 }, + }); + + triggerGestureCallback( + _gestureNode, + 'onBegin', + eventObj, + mockGestureManager, + ); + }); + + expect(mockGestureManager.__ConsumeGesture).toBeCalledWith( + _gestureNode, + _gestureNode.gesture.id, + { consume: true, inner: false }, + ); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts new file mode 100644 index 0000000000..93954072c6 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts @@ -0,0 +1,250 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import { + DefaultScrollGesture, + FlingGesture, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; + +const MainThreadFunction = { + _wkltId: 'test:clone', +}; + +describe('Gesture Cloning', () => { + describe('Clone Configuration', () => { + test('should clone PanGesture with config', () => { + const original = new PanGesture(); + original.minDistance(50); + original.enabled(false); + + const cloned = original.clone(); + + expect(cloned.config.minDistance).toBe(50); + expect(cloned.config.enabled).toBe(false); + expect(cloned.id).toBe(original.id); + expect(cloned.execId).toBe(original.execId); + }); + + test('should clone TapGesture with config', () => { + const original = new TapGesture(); + original.maxDuration(300); + original.maxDistance(15); + original.numberOfTaps(2); + + const cloned = original.clone(); + + expect(cloned.config.maxDuration).toBe(300); + expect(cloned.config.maxDistance).toBe(15); + expect(cloned.config.numberOfTaps).toBe(2); + }); + + test('should clone LongPressGesture with config', () => { + const original = new LongPressGesture(); + original.minDuration(800); + original.maxDistance(5); + + const cloned = original.clone(); + + expect(cloned.config.minDuration).toBe(800); + expect(cloned.config.maxDistance).toBe(5); + }); + + test('should clone DefaultScrollGesture with config', () => { + const original = new DefaultScrollGesture(); + original.tapSlop(7); + + const cloned = original.clone(); + + expect(cloned.config.tapSlop).toBe(7); + }); + }); + + describe('Clone Callbacks', () => { + test('should clone gesture with callbacks', () => { + const original = new PanGesture(); + // @ts-expect-error Testing runtime behavior + original.onUpdate(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onBegin(MainThreadFunction); + + const cloned = original.clone(); + + expect(cloned.callbacks.onUpdate).toBeDefined(); + expect(cloned.callbacks.onBegin).toBeDefined(); + }); + + test('should clone all callback types', () => { + const original = new TapGesture(); + // @ts-expect-error Testing runtime behavior + original.onBegin(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onStart(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onEnd(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onTouchesDown(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onTouchesMove(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onTouchesUp(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + original.onTouchesCancel(MainThreadFunction); + + const cloned = original.clone(); + + expect(cloned.callbacks.onBegin).toBeDefined(); + expect(cloned.callbacks.onStart).toBeDefined(); + expect(cloned.callbacks.onEnd).toBeDefined(); + expect(cloned.callbacks.onTouchesDown).toBeDefined(); + expect(cloned.callbacks.onTouchesMove).toBeDefined(); + expect(cloned.callbacks.onTouchesUp).toBeDefined(); + expect(cloned.callbacks.onTouchesCancel).toBeDefined(); + }); + }); + + describe('Clone Relationships', () => { + test('should clone simultaneousWith relationships', () => { + const gesture1 = new PanGesture(); + const gesture2 = new TapGesture(); + + gesture1.externalSimultaneous(gesture2); + const cloned = gesture1.clone(); + + expect(cloned.simultaneousWith.length).toBe( + gesture1.simultaneousWith.length, + ); + expect(cloned.simultaneousWith).toContain(gesture2); + }); + + test('should clone waitFor relationships', () => { + const gesture1 = new PanGesture(); + const gesture2 = new TapGesture(); + + gesture1.externalWaitFor(gesture2); + const cloned = gesture1.clone(); + + expect(cloned.waitFor.length).toBe(gesture1.waitFor.length); + expect(cloned.waitFor).toContain(gesture2); + }); + + test('should clone continueWith relationships', () => { + const gesture1 = new PanGesture(); + const gesture2 = new TapGesture(); + + gesture1.externalContinueWith(gesture2); + const cloned = gesture1.clone(); + + expect(cloned.continueWith.length).toBe(gesture1.continueWith.length); + expect(cloned.continueWith).toContain(gesture2); + }); + + test('should clone all relationships together', () => { + const gesture1 = new PanGesture(); + const gesture2 = new TapGesture(); + const gesture3 = new LongPressGesture(); + const gesture4 = new FlingGesture(); + + gesture1.externalSimultaneous(gesture2); + gesture1.externalWaitFor(gesture3); + gesture1.externalContinueWith(gesture4); + + const cloned = gesture1.clone(); + + expect(cloned.simultaneousWith).toContain(gesture2); + expect(cloned.waitFor).toContain(gesture3); + expect(cloned.continueWith).toContain(gesture4); + }); + }); + + describe('Clone Identity', () => { + test('cloned gesture should have same ID', () => { + const original = new PanGesture(); + const cloned = original.clone(); + + expect(cloned.id).toBe(original.id); + }); + + test('cloned gesture should have same execId', () => { + const original = new PanGesture(); + original.minDistance(50); + original.enabled(false); + + const cloned = original.clone(); + + expect(cloned.execId).toBe(original.execId); + }); + + test('cloned gesture should have same type', () => { + const original = new PanGesture(); + const cloned = original.clone(); + + expect(cloned.type).toBe(original.type); + }); + + test('cloned gesture should be instance of same class', () => { + const gestures = [ + new PanGesture(), + new TapGesture(), + new LongPressGesture(), + new FlingGesture(), + new DefaultScrollGesture(), + ]; + + gestures.forEach((original) => { + const cloned = original.clone(); + expect(cloned.constructor).toBe(original.constructor); + }); + }); + }); + + describe('Clone Behavior', () => { + test('cloned gesture shares config object (shallow copy)', () => { + const original = new PanGesture(); + original.minDistance(50); + + const cloned = original.clone(); + + // Config is shallow copied, so they share the same object + expect(cloned.config).toBe(original.config); + + // Modifying through methods affects both since they share config + cloned.minDistance(100); + expect(original.config.minDistance).toBe(100); + expect(cloned.config.minDistance).toBe(100); + }); + + test('cloned gesture has same execId initially', () => { + const original = new PanGesture(); + original.minDistance(50); + const originalExecId = original.execId; + + const cloned = original.clone(); + + // Clone has same execId as original at time of cloning + expect(cloned.execId).toBe(originalExecId); + }); + }); + + describe('Clone Special Properties', () => { + test('should clone PanGesture distanceSet property', () => { + const original = new PanGesture(); + original.minDistance(50); + expect(original.distanceSet).toBe(true); + + const cloned = original.clone(); + expect(cloned.distanceSet).toBe(true); + }); + + test('should clone __isGesture marker', () => { + const original = new PanGesture(); + const cloned = original.clone(); + + expect(cloned.__isGesture).toBe(true); + }); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts new file mode 100644 index 0000000000..a7b4c43246 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts @@ -0,0 +1,274 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import { + Gesture, + GestureTypeInner, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; +import type { ComposedGesture } from '../src/index.js'; + +describe('Advanced Gesture Composition', () => { + describe('Exclusive Gesture', () => { + test('should create exclusive gesture composition', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Exclusive(pan, tap) as ComposedGesture; + + expect(composed.gestures.length).toBe(2); + expect(composed.gestures[0]).toBe(pan); + expect(composed.gestures[1]).toBe(tap); + }); + + test('should set waitFor relationships in order', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + + Gesture.Exclusive(pan, tap, longPress); + + // tap should wait for pan + expect(tap.waitFor).toContain(pan); + + // longPress should wait for both pan and tap + expect(longPress.waitFor).toContain(pan); + expect(longPress.waitFor).toContain(tap); + }); + + test('should work with nested compositions', () => { + const pan1 = new PanGesture(); + const pan2 = new PanGesture(); + const tap = new TapGesture(); + + const simultaneous = Gesture.Simultaneous(pan1, pan2); + const exclusive = Gesture.Exclusive(simultaneous, tap) as ComposedGesture; + + expect(exclusive.gestures.length).toBe(2); + }); + }); + + describe('Race Gesture', () => { + test('should create race gesture composition', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Race(pan, tap) as ComposedGesture; + + expect(composed.gestures.length).toBe(2); + expect(composed.gestures[0]).toBe(pan); + expect(composed.gestures[1]).toBe(tap); + }); + + test('should work with multiple gestures', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + + const composed = Gesture.Race(pan, tap, longPress) as ComposedGesture; + + expect(composed.gestures.length).toBe(3); + }); + }); + + describe('Nested Compositions', () => { + test('should handle simultaneous inside exclusive', () => { + const pan1 = new PanGesture(); + const pan2 = new PanGesture(); + const tap = new TapGesture(); + + const simultaneous = Gesture.Simultaneous(pan1, pan2); + const exclusive = Gesture.Exclusive(simultaneous, tap); + + expect(exclusive).toBeDefined(); + }); + + test('should handle exclusive inside simultaneous', () => { + const pan = new PanGesture(); + const tap1 = new TapGesture(); + const tap2 = new TapGesture(); + + const exclusive = Gesture.Exclusive(tap1, tap2); + const simultaneous = Gesture.Simultaneous(pan, exclusive); + + expect(simultaneous).toBeDefined(); + }); + + test('should flatten nested compositions correctly', () => { + const pan1 = new PanGesture(); + const pan2 = new PanGesture(); + const tap = new TapGesture(); + + const inner = Gesture.Simultaneous(pan1, pan2); + const outer = Gesture.Simultaneous(inner, tap) as ComposedGesture; + + const flattened = outer.toGestureArray(); + expect(flattened.length).toBe(3); + }); + }); + + describe('External Relationships with Compositions', () => { + test('should handle externalWaitFor with composed gesture', () => { + const pan = new PanGesture(); + const tap1 = new TapGesture(); + const tap2 = new TapGesture(); + + const composed = Gesture.Simultaneous(tap1, tap2); + pan.externalWaitFor(composed); + + // Should not throw + expect(pan.waitFor.length).toBeGreaterThanOrEqual(0); + }); + + test('should handle externalSimultaneous with composed gesture', () => { + const pan = new PanGesture(); + const tap1 = new TapGesture(); + const tap2 = new TapGesture(); + + const composed = Gesture.Simultaneous(tap1, tap2); + pan.externalSimultaneous(composed); + + expect(pan.simultaneousWith.length).toBeGreaterThanOrEqual(0); + }); + + test('should handle externalContinueWith with composed gesture', () => { + const pan = new PanGesture(); + const tap1 = new TapGesture(); + const tap2 = new TapGesture(); + + const composed = Gesture.Simultaneous(tap1, tap2); + pan.externalContinueWith(composed); + + expect(pan.continueWith.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Pan Distance Processing', () => { + test('should only process pan distance once', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture; + + composed.processPanDistance(); + const firstDistance = pan.config.minDistance; + + composed.processPanDistance(); + const secondDistance = pan.config.minDistance; + + expect(firstDistance).toBe(secondDistance); + }); + + test('should process pan distance with longPress', () => { + const pan = new PanGesture(); + const longPress = new LongPressGesture(); + + const composed = Gesture.Simultaneous(pan, longPress) as ComposedGesture; + composed.processPanDistance(); + + expect(pan.config.minDistance).toBe(10); // DEFAULT_DISTANCE + }); + + test('should not override user-set pan distance', () => { + const pan = new PanGesture(); + pan.minDistance(50); + const tap = new TapGesture(); + + const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture; + composed.processPanDistance(); + + expect(pan.config.minDistance).toBe(50); + }); + }); + + describe('Gesture Array Conversion', () => { + test('should convert simple gesture to array', () => { + const pan = new PanGesture(); + const array = pan.toGestureArray(); + + expect(array.length).toBe(1); + expect(array[0]).toBe(pan); + }); + + test('should flatten composed gesture to array', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + + const composed = Gesture.Simultaneous( + pan, + tap, + longPress, + ) as ComposedGesture; + const array = composed.toGestureArray(); + + expect(array.length).toBe(3); + expect(array).toContain(pan); + expect(array).toContain(tap); + expect(array).toContain(longPress); + }); + }); + + describe('Serialization', () => { + test('should serialize composed gesture', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture; + const serialized = composed.serialize(); + + expect(serialized.__isSerialized).toBe(true); + expect(serialized.gestures).toBeDefined(); + expect(Array.isArray(serialized.gestures)).toBe(true); + }); + + test('should serialize gesture with relationships', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + pan.externalWaitFor(tap); + + const serialized = pan.serialize(); + + expect(serialized.waitFor).toBeDefined(); + expect(Array.isArray(serialized.waitFor)).toBe(true); + }); + + test('should serialize composed gestures with relationships', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed1 = Gesture.Simultaneous(pan, tap); + + const serialized = composed1.serialize(); + + expect(serialized.type).toBe(GestureTypeInner.COMPOSED); + }); + + test('should use toJSON for serialization', () => { + const pan = new PanGesture(); + pan.minDistance(100); + + const json = pan.toJSON(); + const serialized = pan.serialize(); + + expect(json).toEqual(serialized); + }); + }); + + describe('corner cases', () => { + test('should not throw if gesture relation is undefined', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + delete pan.simultaneousWith; + + const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture; + expect(() => composed.serialize()).not.toThrow(); + }); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts new file mode 100644 index 0000000000..3ca319e607 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts @@ -0,0 +1,221 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import { + DefaultScrollGesture, + FlingGesture, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; + +describe('Gesture Configuration', () => { + describe('PanGesture', () => { + test('should set minDistance', () => { + const pan = new PanGesture(); + pan.minDistance(50); + expect(pan.config.minDistance).toBe(50); + }); + + test('should track if distance was set by user', () => { + const pan = new PanGesture(); + expect(pan.distanceSet).toBe(false); + + pan.minDistance(100); + expect(pan.distanceSet).toBe(true); + }); + + test('should reset distanceSet when minDistance is undefined', () => { + const pan = new PanGesture(); + pan.minDistance(100); + expect(pan.distanceSet).toBe(true); + + // @ts-expect-error Testing runtime behavior + pan.minDistance(undefined); + expect(pan.distanceSet).toBe(false); + }); + + test('should override default minDistance', () => { + const pan = new PanGesture(); + pan.overrideDefaultMinDistance(); + expect(pan.config.minDistance).toBe(10); // DEFAULT_DISTANCE + }); + + test('should not override user-set minDistance', () => { + const pan = new PanGesture(); + pan.minDistance(50); + pan.overrideDefaultMinDistance(); + expect(pan.config.minDistance).toBe(50); + }); + + test('should support method chaining', () => { + const pan = new PanGesture() + .minDistance(20) + .enabled(false); + + expect(pan.config.minDistance).toBe(20); + expect(pan.config.enabled).toBe(false); + }); + }); + + describe('TapGesture', () => { + test('should set maxDuration', () => { + const tap = new TapGesture(); + tap.maxDuration(300); + expect(tap.config.maxDuration).toBe(300); + }); + + test('should set maxDistance', () => { + const tap = new TapGesture(); + tap.maxDistance(15); + expect(tap.config.maxDistance).toBe(15); + }); + + test('should set numberOfTaps', () => { + const tap = new TapGesture(); + tap.numberOfTaps(2); + expect(tap.config.numberOfTaps).toBe(2); + }); + + test('should support method chaining', () => { + const tap = new TapGesture() + .maxDuration(400) + .maxDistance(20) + .numberOfTaps(3); + + expect(tap.config.maxDuration).toBe(400); + expect(tap.config.maxDistance).toBe(20); + expect(tap.config.numberOfTaps).toBe(3); + }); + + test('should have default values', () => { + const tap = new TapGesture(); + expect(tap.config.enabled).toBe(true); + expect(tap.config.maxDuration).toBe(500); + expect(tap.config.maxDistance).toBe(10); + }); + }); + + describe('LongPressGesture', () => { + test('should set minDuration', () => { + const longPress = new LongPressGesture(); + longPress.minDuration(800); + expect(longPress.config.minDuration).toBe(800); + }); + + test('should set maxDistance', () => { + const longPress = new LongPressGesture(); + longPress.maxDistance(5); + expect(longPress.config.maxDistance).toBe(5); + }); + + test('should support method chaining', () => { + const longPress = new LongPressGesture() + .minDuration(1000) + .maxDistance(8); + + expect(longPress.config.minDuration).toBe(1000); + expect(longPress.config.maxDistance).toBe(8); + }); + + test('should have default values', () => { + const longPress = new LongPressGesture(); + expect(longPress.config.enabled).toBe(true); + expect(longPress.config.minDuration).toBe(500); + expect(longPress.config.maxDistance).toBe(10); + }); + }); + + describe('FlingGesture', () => { + test('should create with default config', () => { + const fling = new FlingGesture(); + expect(fling.config.enabled).toBe(true); + }); + + test('should support enabled configuration', () => { + const fling = new FlingGesture(); + fling.enabled(false); + expect(fling.config.enabled).toBe(false); + }); + }); + + describe('DefaultScrollGesture', () => { + test('should set tapSlop', () => { + const defaultScrollGesture = new DefaultScrollGesture(); + defaultScrollGesture.tapSlop(5); + expect(defaultScrollGesture.config.tapSlop).toBe(5); + }); + + test('should have default tapSlop value', () => { + const defaultScrollGesture = new DefaultScrollGesture(); + expect(defaultScrollGesture.config.tapSlop).toBe(3); + }); + + test('should support method chaining', () => { + const defaultScrollGesture = new DefaultScrollGesture() + .tapSlop(7) + .enabled(false); + + expect(defaultScrollGesture.config.tapSlop).toBe(7); + expect(defaultScrollGesture.config.enabled).toBe(false); + }); + }); + + describe('Common Configuration', () => { + test('all gestures should support enabled()', () => { + const gestures = [ + new PanGesture(), + new TapGesture(), + new LongPressGesture(), + new FlingGesture(), + new DefaultScrollGesture(), + ]; + + gestures.forEach((gesture) => { + gesture.enabled(false); + expect(gesture.config.enabled).toBe(false); + }); + }); + + test('all gestures should have unique IDs', () => { + const gestures = [ + new PanGesture(), + new TapGesture(), + new LongPressGesture(), + new FlingGesture(), + new DefaultScrollGesture(), + ]; + + const ids = gestures.map((g) => g.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(gestures.length); + }); + + test('all gestures should start with execId 0', () => { + const gestures = [ + new PanGesture(), + new TapGesture(), + new LongPressGesture(), + new FlingGesture(), + new DefaultScrollGesture(), + ]; + + gestures.forEach((gesture) => { + expect(gesture.execId).toBe(0); + }); + }); + + test('execId should increment on config change', () => { + const pan = new PanGesture(); + expect(pan.execId).toBe(0); + + pan.minDistance(10); + expect(pan.execId).toBe(1); + + pan.enabled(false); + expect(pan.execId).toBe(2); + }); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts new file mode 100644 index 0000000000..b764793f59 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts @@ -0,0 +1,200 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { afterEach, describe, expect, test } from 'vitest'; + +import { + DefaultScrollGesture, + FlingGesture, + Gesture, + GestureTypeInner, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; +import type { ComposedGesture } from '../src/index.js'; +import { DEFAULT_DISTANCE } from '../src/utils/const.js'; + +const MainThreadFunction = { + _wkltId: 'bdd4:dd564:2', +}; + +describe('create gesture', () => { + test('gesture type', () => { + const panGesture = new PanGesture(); + const flingGesture = new FlingGesture(); + const tapGesture = new TapGesture(); + const longPressGesture = new LongPressGesture(); + + expect(panGesture.type).toBe(GestureTypeInner.PAN); + expect(flingGesture.type).toBe(GestureTypeInner.FLING); + expect(tapGesture.type).toBe(GestureTypeInner.TAP); + expect(longPressGesture.type).toBe(GestureTypeInner.LONGPRESS); + }); + + test('gesture config', () => { + const panGesture = new PanGesture(); + panGesture.minDistance(100); + + expect(panGesture.config.minDistance).toBe(100); + + const longPressGesture = new LongPressGesture(); + longPressGesture.minDuration(1000); + longPressGesture.maxDistance(200); + + expect(longPressGesture.config.minDuration).toBe(1000); + expect(longPressGesture.config.maxDistance).toBe(200); + + const tapGesture = new TapGesture(); + tapGesture.maxDuration(1000); + tapGesture.maxDistance(200); + + expect(tapGesture.config.maxDuration).toBe(1000); + expect(tapGesture.config.maxDistance).toBe(200); + }); + + test('gesture non main thread callback', () => { + const panGesture = new PanGesture(); + + expect(() => + panGesture.onUpdate((event) => { + // Non Main Thread Callback + }) + ).toThrow( + `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`, + ); + }); + + test('gesture callbacks should have callbacks set', () => { + const panGesture = new TapGesture(); + // @ts-expect-error Expected + panGesture.onBegin(MainThreadFunction); + // @ts-expect-error Expected + panGesture.onEnd(MainThreadFunction); + // @ts-expect-error Expected + panGesture.onTouchesDown(MainThreadFunction); + // @ts-expect-error Expected + panGesture.onTouchesMove(MainThreadFunction); + // @ts-expect-error Expected + panGesture.onTouchesUp(MainThreadFunction); + // @ts-expect-error Expected + panGesture.onTouchesCancel(MainThreadFunction); + + expect(panGesture.callbacks.onBegin).toMatchObject({ + _c: {}, + _wkltId: expect.any(String), + }); + expect(panGesture.callbacks.onEnd).toMatchObject({ + _c: {}, + _wkltId: expect.any(String), + }); + expect(panGesture.callbacks.onTouchesDown).toMatchObject({ + _c: {}, + _wkltId: expect.any(String), + }); + expect(panGesture.callbacks.onTouchesMove).toMatchObject({ + _c: {}, + _wkltId: expect.any(String), + }); + expect(panGesture.callbacks.onTouchesUp).toMatchObject({ + _c: {}, + _wkltId: expect.any(String), + }); + expect(panGesture.callbacks.onTouchesCancel).toMatchObject({ + _c: {}, + _wkltId: expect.any(String), + }); + }); + + test('clone should have equal properties', () => { + const defaultScrollGesture = new DefaultScrollGesture(); + defaultScrollGesture.tapSlop(100); + + const cloned = defaultScrollGesture.clone(); + + expect(cloned).toMatchObject(defaultScrollGesture); + }); +}); + +describe('gesture composition', () => { + test('Simultaneous Should have gestures in simultaneousWith', () => { + const panGesture = new PanGesture(); + const tapGesture = new TapGesture(); + + const composed = Gesture.Simultaneous( + panGesture, + tapGesture, + ) as ComposedGesture; + + expect(composed.gestures.length).toBe(2); + expect(composed.gestures[0]).toBe(panGesture); + expect(composed.gestures[1]).toBe(tapGesture); + + expect(panGesture.simultaneousWith).toContain(tapGesture); + expect(tapGesture.simultaneousWith).toContain(panGesture); + }); + + test('pan gesture with tap have default minDistance', () => { + const panGesture = new PanGesture(); + const tapGesture = new TapGesture(); + + const composed = Gesture.Simultaneous( + panGesture, + tapGesture, + ) as ComposedGesture; + composed.processPanDistance(); + + expect(panGesture.config.minDistance).toBe(DEFAULT_DISTANCE); + + const panGesture2 = new PanGesture(); + panGesture2.minDistance(100); + const tapGesture2 = new TapGesture(); + + const composed2 = Gesture.Simultaneous( + panGesture2, + tapGesture2, + ) as ComposedGesture; + composed2.processPanDistance(); + + expect(panGesture2.config.minDistance).toBe(100); + }); + + test('externalWaitFor should have gestures in waitFor', () => { + const panGesture = new PanGesture(); + const tapGesture = new TapGesture(); + + tapGesture.externalWaitFor(panGesture); + + expect(tapGesture.waitFor).toContain(panGesture); + }); + + test('externalSimultaneous should have gestures in simultaneousWith', () => { + const panGesture = new PanGesture(); + const tapGesture = new TapGesture(); + + panGesture.externalSimultaneous(tapGesture); + + expect(panGesture.simultaneousWith).toContain(tapGesture); + }); + + test('externalContinueWith should have gestures in continueWith', () => { + const panGesture = new PanGesture(); + const tapGesture = new TapGesture(); + + panGesture.externalContinueWith(tapGesture); + + expect(panGesture.continueWith).toContain(tapGesture); + }); + + test('self external should being skipped', () => { + const panGesture = new PanGesture(); + + panGesture.externalContinueWith(panGesture); + panGesture.externalSimultaneous(panGesture); + panGesture.externalWaitFor(panGesture); + + expect(panGesture.continueWith).not.toContain(panGesture); + expect(panGesture.simultaneousWith).not.toContain(panGesture); + expect(panGesture.waitFor).not.toContain(panGesture); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx new file mode 100644 index 0000000000..0c5a468ae5 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx @@ -0,0 +1,348 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; + +import { useState } from '@lynx-js/react'; +import { + act, + fireEvent, + render, + screen, + waitSchedule, +} from '@lynx-js/react/testing-library'; + +import { + DefaultScrollGesture, + FlingGesture, + Gesture, + GestureTypeInner, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; +import type { ComposedGesture } from '../src/index.js'; +import { useGesture } from '../src/useGesture.js'; +import { DEFAULT_DISTANCE } from '../src/utils/const.js'; + +const MainThreadFunction = function() { + 'main thread'; +}; + +describe('create gesture', () => { + test('gesture type', () => { + const panGesture = new PanGesture(); + const flingGesture = new FlingGesture(); + const tapGesture = new TapGesture(); + const longPressGesture = new LongPressGesture(); + + expect(panGesture.type).toBe(GestureTypeInner.PAN); + expect(flingGesture.type).toBe(GestureTypeInner.FLING); + expect(tapGesture.type).toBe(GestureTypeInner.TAP); + expect(longPressGesture.type).toBe(GestureTypeInner.LONGPRESS); + }); + + test('gesture config', () => { + const panGesture = new PanGesture(); + panGesture.minDistance(100); + + expect(panGesture.config.minDistance).toBe(100); + + const longPressGesture = new LongPressGesture(); + longPressGesture.minDuration(1000); + longPressGesture.maxDistance(200); + + expect(longPressGesture.config.minDuration).toBe(1000); + expect(longPressGesture.config.maxDistance).toBe(200); + + const tapGesture = new TapGesture(); + tapGesture.maxDuration(1000); + tapGesture.maxDistance(200); + + expect(tapGesture.config.maxDuration).toBe(1000); + expect(tapGesture.config.maxDistance).toBe(200); + }); + + test('gesture callback', () => { + const panGesture = new PanGesture(); + panGesture.onUpdate(MainThreadFunction); + + expect(panGesture.callbacks.onUpdate).toMatchObject({}); + }); + + test('gesture non main thread callback', () => { + const panGesture = new PanGesture(); + + expect(() => + panGesture.onUpdate((event) => { + // Non Main Thread Callback + }) + ).toThrow( + `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`, + ); + }); + + test('clone should have equal properties', () => { + const defaultScrollGesture = new DefaultScrollGesture(); + defaultScrollGesture.tapSlop(100); + + const cloned = defaultScrollGesture.clone(); + + expect(cloned).toMatchObject(defaultScrollGesture); + }); +}); + +describe('useGesture', () => { + test('useGesture should create gesture instance', () => { + let _panGesture; + let _tapGesture; + let _flingGesture; + let _longPressGesture; + + const App = () => { + const panGesture = useGesture(PanGesture); + const tapGesture = useGesture(TapGesture); + const flingGesture = useGesture(FlingGesture); + const longPressGesture = useGesture(LongPressGesture); + + _panGesture = panGesture; + _tapGesture = tapGesture; + _flingGesture = flingGesture; + _longPressGesture = longPressGesture; + return ; + }; + + const { container } = render(); + + expect(_panGesture).toBeInstanceOf(PanGesture); + expect(_tapGesture).toBeInstanceOf(TapGesture); + expect(_flingGesture).toBeInstanceOf(FlingGesture); + expect(_longPressGesture).toBeInstanceOf(LongPressGesture); + }); + + test('useGesture should create different instance when updated', async () => { + let _panGesture; + let prevPanExecId = 0; + let _setCurrent; + + const App = () => { + const panGesture = useGesture(PanGesture); + const [current, setCurrent] = useState(0); + + _setCurrent = setCurrent; + + panGesture.onBegin(() => { + 'main thread'; + // empty callback + }); + + _panGesture = panGesture; + return ; + }; + + await act(() => { + const { container } = render(); + }); + + prevPanExecId = _panGesture; + + await act(() => { + _setCurrent(20); + }); + + expect(_panGesture).not.toBe(prevPanExecId); + }); +}); + +describe('gestures mt', () => { + let spySetGesture: MockInstance; + + beforeEach(() => { + spySetGesture = vi.spyOn( + lynxTestingEnv.mainThread.globalThis, + '__SetGestureDetector', + ); + }); + + afterEach(() => { + spySetGesture.mockRestore(); + }); + + test('bind gestures should call __SetGestureDetector correctly', async () => { + let _panGesture; + + const App = () => { + const panGesture = useGesture(PanGesture); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + expect(spySetGesture).toHaveBeenCalled(); + + const [viewElement, gestureId, gestureType, gestureConfig, relationConfig] = + spySetGesture.mock.calls[0]; + + // Assert view element properties (not the whole element) + expect(viewElement.getAttribute('flatten')).toBe('false'); + expect(viewElement.getAttribute('has-react-gesture')).toBe('true'); + + // Assert gesture ID is a number (don't care about specific value) + expect(typeof gestureId).toBe('number'); + expect(gestureId).toBeGreaterThan(0); + + // Assert priority + expect(gestureType).toBe(GestureTypeInner.PAN); + + // Assert gesture config + expect(gestureConfig).toEqual({ + callbacks: [], + config: { + enabled: true, + minDistance: 0, + }, + }); + + // Assert relation config + expect(relationConfig).toEqual({ + continueWith: [], + simultaneous: [], + waitFor: [], + }); + }); + + test('bind gestures should call __SetGestureDetector with relationsConfig', async () => { + let _panGesture; + + const App = () => { + const panGesture = useGesture(PanGesture); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + expect(spySetGesture).toHaveBeenCalled(); + + const [viewElement, gestureId, gestureType, gestureConfig, relationConfig] = + spySetGesture.mock.calls[0]; + + // Assert view element properties (not the whole element) + expect(viewElement.getAttribute('flatten')).toBe('false'); + expect(viewElement.getAttribute('has-react-gesture')).toBe('true'); + + // Assert gesture ID is a number (don't care about specific value) + expect(typeof gestureId).toBe('number'); + expect(gestureId).toBeGreaterThan(0); + + // Assert priority + expect(gestureType).toBe(GestureTypeInner.PAN); + + // Assert gesture config + expect(gestureConfig).toEqual({ + callbacks: [], + config: { + enabled: true, + minDistance: 0, + }, + }); + + // Assert relation config + expect(relationConfig).toEqual({ + continueWith: [], + simultaneous: [], + waitFor: [], + }); + }); +}); + +describe('test processGesture in MTS', () => { + let spySetGesture: MockInstance; + + beforeEach(() => { + spySetGesture = vi.spyOn( + lynxTestingEnv.mainThread.globalThis, + '__SetGestureDetector', + ); + }); + + afterEach(() => { + spySetGesture.mockRestore(); + }); + + test('Old ReactLynx would call __SetGestureDetector correctly', async () => { + let _panGesture; + + const App = () => { + const panGesture = useGesture(PanGesture); + + _panGesture = panGesture; + return ( + + + + ); + }; + + await act(() => { + const { container } = render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + }); + + expect(spySetGesture).toHaveBeenCalled(); + + const [viewElement, gestureId, gestureType, gestureConfig, relationConfig] = + spySetGesture.mock.calls[0]; + + // Assert view element properties (not the whole element) + expect(viewElement.getAttribute('flatten')).toBe('false'); + expect(viewElement.getAttribute('has-react-gesture')).toBe('true'); + + // Assert gesture ID is a number (don't care about specific value) + expect(typeof gestureId).toBe('number'); + expect(gestureId).toBeGreaterThan(0); + + // Assert priority + expect(gestureType).toBe(GestureTypeInner.PAN); + + // Assert gesture config + expect(gestureConfig).toEqual({ + callbacks: [], + config: { + enabled: true, + minDistance: 0, + }, + }); + + // Assert relation config + expect(relationConfig).toEqual({ + continueWith: [], + simultaneous: [], + waitFor: [], + }); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx new file mode 100644 index 0000000000..cc8a397d45 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx @@ -0,0 +1,191 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import { + ComposedGesture, + Gesture, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; + +describe('Gesture Relations and Edge Cases', () => { + test('should extend undefined relation with new gestures', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + + // Create a fresh pan gesture with no existing relations + const freshPan = new PanGesture(); + + // Verify it starts with empty arrays + expect(freshPan.simultaneousWith).toHaveLength(0); + expect(freshPan.waitFor).toHaveLength(0); + + // Now add relations through composition + const composed = new ComposedGesture(freshPan, tap, longPress); + composed.simultaneousWith = [pan]; + composed.prepare(); + + // The fresh pan should now have the relations + expect(freshPan.simultaneousWith).toContain(pan); + }); + + test('should handle BaseGesture in prepareSingleGesture', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const fling = new PanGesture(); + + const composed = new ComposedGesture(pan, tap); + + // Test prepareSingleGesture with BaseGesture + composed.prepareSingleGesture(fling, [pan, tap], []); + + expect(fling.simultaneousWith).toContain(pan); + expect(fling.simultaneousWith).toContain(tap); + }); + + test('should handle ComposedGesture in prepareSingleGesture', () => { + const pan1 = new PanGesture(); + const pan2 = new PanGesture(); + const tap = new TapGesture(); + + const innerComposed = new ComposedGesture(pan1, pan2); + const outerComposed = new ComposedGesture(innerComposed, tap); + + // Test prepareSingleGesture with ComposedGesture + outerComposed.prepareSingleGesture(innerComposed, [tap], []); + + expect(innerComposed.simultaneousWith).toContain(tap); + }); + + test('should extend existing relations', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + const fling = new PanGesture(); + + // Add initial relation + pan.externalSimultaneous(tap); + expect(pan.simultaneousWith).toContain(tap); + + // Now extend with more relations through composition + const composed = new ComposedGesture(pan, longPress); + composed.simultaneousWith = [fling]; + composed.prepare(); + + // Pan should have both old and new relations + expect(pan.simultaneousWith).toContain(tap); + expect(pan.simultaneousWith).toContain(fling); + }); + + test('should handle waitFor relations extension', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + + // Add initial waitFor + pan.externalWaitFor(tap); + + // Extend through composition + const composed = new ComposedGesture(pan, longPress); + composed.waitFor = [longPress]; + composed.prepare(); + + expect(pan.waitFor).toContain(tap); + expect(pan.waitFor).toContain(longPress); + }); + + test('should serialize composed gesture with all fields', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + const longPress = new LongPressGesture(); + + const composed = Gesture.Simultaneous(pan, tap); + + const serialized = composed.serialize(); + + expect(serialized.__isSerialized).toBe(true); + expect(serialized.type).toBeDefined(); + expect(serialized.gestures).toBeDefined(); + }); + + test('should handle toJSON on composed gesture', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Exclusive(pan, tap); + const json = composed.toJSON(); + + expect(json).toEqual(composed.serialize()); + }); + + test('should flatten nested composed gestures', () => { + const pan1 = new PanGesture(); + const pan2 = new PanGesture(); + const tap1 = new TapGesture(); + const tap2 = new TapGesture(); + + const inner1 = Gesture.Simultaneous(pan1, pan2); + const inner2 = Gesture.Simultaneous(tap1, tap2); + const outer = Gesture.Exclusive(inner1, inner2); + + const flattened = outer.toGestureArray(); + + expect(flattened).toContain(pan1); + expect(flattened).toContain(pan2); + expect(flattened).toContain(tap1); + expect(flattened).toContain(tap2); + }); + + test('should handle processPanDistance with multiple calls', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture; + + // First call + composed.processPanDistance(); + expect(composed.panProcessed).toBe(true); + + // Second call should return early + composed.processPanDistance(); + expect(composed.panProcessed).toBe(true); + }); + + test('should handle empty gesture array in toGestureArray', () => { + const composed = new ComposedGesture(); + const array = composed.toGestureArray(); + + expect(array).toHaveLength(0); + }); + + test('should handle single gesture in SimultaneousGesture', () => { + const pan = new PanGesture(); + const composed = Gesture.Simultaneous(pan); + + expect(composed.gestures).toHaveLength(1); + expect(composed.gestures[0]).toBe(pan); + }); + + test('should handle ExclusiveGesture with single gesture', () => { + const pan = new PanGesture(); + const composed = Gesture.Exclusive(pan); + + expect(composed.gestures).toHaveLength(1); + expect(pan.waitFor).toHaveLength(0); + }); + + test('should handle RaceGesture', () => { + const pan = new PanGesture(); + const tap = new TapGesture(); + + const composed = Gesture.Race(pan, tap); + + expect(composed.gestures).toHaveLength(2); + expect(composed.gestures[0]).toBe(pan); + expect(composed.gestures[1]).toBe(tap); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts b/packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts new file mode 100644 index 0000000000..d0c43639db --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts @@ -0,0 +1,86 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import { isWorkletObj } from '../src/utils/isWorkletObject.js'; + +describe('isWorkletObj', () => { + test('should return true in MAIN THREAD mode', () => { + // @ts-expect-error Testing MAIN THREAD mode + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const original = globalThis.__MAIN_THREAD__; + // @ts-expect-error Testing MAIN THREAD mode + globalThis.__MAIN_THREAD__ = true; + + const result = isWorkletObj({}); + + // @ts-expect-error Restore + globalThis.__MAIN_THREAD__ = original; + + expect(result).toBe(true); + }); + + test('should return true for object with _wkltId', () => { + const worklet = { + _wkltId: 'test:worklet:1', + }; + + expect(isWorkletObj(worklet)).toBe(true); + }); + + test('should return false for null', () => { + expect(isWorkletObj(null)).toBe(false); + }); + + test('should return false for undefined', () => { + expect(isWorkletObj(undefined)).toBe(false); + }); + + test('should return false for object without _wkltId', () => { + const notWorklet = { + someProperty: 'value', + }; + + expect(isWorkletObj(notWorklet)).toBe(false); + }); + + test('should return false for primitive values', () => { + expect(isWorkletObj(123)).toBe(false); + expect(isWorkletObj('string')).toBe(false); + expect(isWorkletObj(true)).toBe(false); + }); + + test('should return false for empty object', () => { + expect(isWorkletObj({})).toBe(false); + }); + + test('should return true for object with _wkltId even if it has other properties', () => { + const worklet = { + _wkltId: 'test:worklet:1', + _execId: 123, + otherProp: 'value', + }; + + expect(isWorkletObj(worklet)).toBe(true); + }); + + test('should return false for array', () => { + expect(isWorkletObj([])).toBe(false); + expect(isWorkletObj([1, 2, 3])).toBe(false); + }); + + test('should return false for function', () => { + const fn = () => {}; + expect(isWorkletObj(fn)).toBe(false); + }); + + test('should handle object with _wkltId as falsy value', () => { + const worklet = { + _wkltId: '', + }; + + // Still has the property, so should return true + expect(isWorkletObj(worklet)).toBe(true); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/type-safety.test.ts b/packages/lynx/gesture-runtime/__test__/type-safety.test.ts new file mode 100644 index 0000000000..116d80b762 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/type-safety.test.ts @@ -0,0 +1,98 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from 'vitest'; + +import { + DefaultScrollGesture, + FlingGesture, + LongPressGesture, + PanGesture, + TapGesture, +} from '../src/index.js'; +import type { + DefaultGestureConfig, + FlingGestureConfig, + GestureConfigType, + GestureEventType, + LongPressGestureConfig, + PanGestureConfig, + TapGestureConfig, +} from '../src/index.js'; + +const MainThreadFunction = { + _wkltId: 'test:type-safety', +}; + +describe('Type Safety', () => { + test('PanGesture should have typed config', () => { + const pan = new PanGesture(); + pan.minDistance(100); + + // TypeScript should infer correct type + const config: PanGestureConfig = pan.config; + expect(config.minDistance).toBe(100); + expect(config.enabled).toBe(true); + }); + + test('TapGesture should have typed config', () => { + const tap = new TapGesture(); + tap.maxDuration(1000); + tap.maxDistance(20); + tap.numberOfTaps(2); + + const config: TapGestureConfig = tap.config; + expect(config.maxDuration).toBe(1000); + expect(config.maxDistance).toBe(20); + expect(config.numberOfTaps).toBe(2); + }); + + test('LongPressGesture should have typed config', () => { + const longPress = new LongPressGesture(); + longPress.minDuration(800); + longPress.maxDistance(15); + + const config: LongPressGestureConfig = longPress.config; + expect(config.minDuration).toBe(800); + expect(config.maxDistance).toBe(15); + }); + + test('FlingGesture should have typed config', () => { + const fling = new FlingGesture(); + + const config: FlingGestureConfig = fling.config; + expect(config.enabled).toBe(true); + }); + + test('DefaultScrollGesture should have typed config', () => { + const defaultScrollGesture = new DefaultScrollGesture(); + defaultScrollGesture.tapSlop(5); + + const config: DefaultGestureConfig = defaultScrollGesture.config; + expect(config.tapSlop).toBe(5); + }); + + test('Method chaining should preserve type', () => { + const pan = new PanGesture() + .minDistance(50) + // @ts-expect-error Testing runtime behavior + .onUpdate(MainThreadFunction) + // @ts-expect-error Testing runtime behavior + .onEnd(MainThreadFunction); + + // Should still be PanGesture type + expect(pan).toBeInstanceOf(PanGesture); + expect(pan.config.minDistance).toBe(50); + }); + + test('Callbacks should be set correctly', () => { + const pan = new PanGesture(); + // @ts-expect-error Testing runtime behavior + pan.onUpdate(MainThreadFunction); + // @ts-expect-error Testing runtime behavior + pan.onBegin(MainThreadFunction); + + expect(pan.callbacks.onUpdate).toBeDefined(); + expect(pan.callbacks.onBegin).toBeDefined(); + }); +}); diff --git a/packages/lynx/gesture-runtime/__test__/utils/callback.ts b/packages/lynx/gesture-runtime/__test__/utils/callback.ts new file mode 100644 index 0000000000..3036a7057e --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/utils/callback.ts @@ -0,0 +1,64 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import type { + ConsumeGestureParams, + SetGestureStateType, +} from '../../src/gestureInterface.js'; +import type { GestureConfig } from '../../src/processGesture.js'; + +export class MockGestureManager { + __SetGestureState: Mock< + (dom: any, id: number, type: SetGestureStateType) => void + > = vi.fn((dom: any, id: number, type: SetGestureStateType) => {}); + __ConsumeGesture: Mock< + (dom: any, id: number, params: ConsumeGestureParams) => void + > = vi.fn((dom: any, id: number, params: ConsumeGestureParams) => {}); +} + +function getCallback(config: GestureConfig, method: string) { + if (config.callbacks) { + return config.callbacks.find((callback) => callback.name === method); + } else { + return undefined; + } +} + +export function triggerGestureCallback( + node: any, + method: string, + event: any, + manager: MockGestureManager, +): void { + const callbackObj = getCallback(node?.gesture?.config, method); + if (callbackObj) { + globalThis.runWorklet(callbackObj.callback, [ + event, + manager, + ]); + } else { + throw new Error(`No gesture callback for ${method}`); + } +} + +export function genEventObj(target: any, body: Record): { + target: { + elementRefptr: any; + }; + currentTarget: { + elementRefptr: any; + }; +} { + return { + target: { + elementRefptr: target, + }, + currentTarget: { + elementRefptr: target, + }, + ...body, + }; +} diff --git a/packages/lynx/gesture-runtime/__test__/utils/setup.ts b/packages/lynx/gesture-runtime/__test__/utils/setup.ts new file mode 100644 index 0000000000..9ab48a2ba8 --- /dev/null +++ b/packages/lynx/gesture-runtime/__test__/utils/setup.ts @@ -0,0 +1,3 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. diff --git a/packages/lynx/gesture-runtime/package.json b/packages/lynx/gesture-runtime/package.json new file mode 100644 index 0000000000..9c89042973 --- /dev/null +++ b/packages/lynx/gesture-runtime/package.json @@ -0,0 +1,38 @@ +{ + "name": "@lynx-js/gesture-runtime", + "version": "2.0.0", + "description": "Lynx Gesture", + "license": "ISC", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "CHANGELOG.md", + "README.md", + "dist", + "src" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch", + "test": "vitest run" + }, + "devDependencies": { + "@lynx-js/react": "workspace:*", + "@lynx-js/types": "3.4.11", + "@testing-library/jest-dom": "^6.9.0", + "rsbuild-plugin-publint": "0.3.3" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@lynx-js/types": "*" + } +} diff --git a/packages/lynx/gesture-runtime/rslib.config.ts b/packages/lynx/gesture-runtime/rslib.config.ts new file mode 100644 index 0000000000..5f794e9aa6 --- /dev/null +++ b/packages/lynx/gesture-runtime/rslib.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@rslib/core'; +import { pluginPublint } from 'rsbuild-plugin-publint'; + +export default defineConfig({ + lib: [ + { + format: 'esm', + syntax: 'es2022', + dts: true, + bundle: false, + }, + ], + source: { + tsconfigPath: './tsconfig.build.json', + }, + plugins: [pluginPublint()], +}); diff --git a/packages/lynx/gesture-runtime/src/baseGesture.ts b/packages/lynx/gesture-runtime/src/baseGesture.ts new file mode 100644 index 0000000000..0b59a405a8 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/baseGesture.ts @@ -0,0 +1,303 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { + BaseGestureCallbacks, + BaseGestureConfig, + ContinuousGestureCallbacks, + GestureCallback, + GestureChangeEvent, + GestureKind, + InternalStateManager, + StateManager as StateManagerType, +} from './gestureInterface.js'; +import { GestureTypeInner, SetGestureStateType } from './gestureInterface.js'; +import { isWorkletObj } from './utils/isWorkletObject.js'; +import { removeUndefined } from './utils/removeUndefined.js'; + +let gestureID = 0; + +type WrappedCallback = ( + event: GestureChangeEvent, + nativeStateManager: InternalStateManager, +) => void; + +function wrapCallback( + cb: GestureCallback, + id: number, + name?: keyof BaseGestureCallbacks | keyof ContinuousGestureCallbacks, +): WrappedCallback { + if (cb && !isWorkletObj(cb)) { + throw new Error( + `Gesture Callback Must be a Main Thread Function, check callback of ${name}'s callback`, + ); + } + + /* c8 ignore next -- Never reached */ + return ( + event: GestureChangeEvent, + nativeStateManager: InternalStateManager, + ) => { + 'main thread'; + /** + * This should be declared inside worklet + * So it can be a worklet accessible class declaration + */ + class StateManager implements StateManagerType { + gestureId: number; + stateManager: InternalStateManager; + event: GestureChangeEvent; + + constructor( + id: number, + stateManager: InternalStateManager, + event: GestureChangeEvent, + ) { + this.gestureId = id; + this.stateManager = stateManager; + this.event = event; + } + + fail() { + const result = this.stateManager.__SetGestureState( + this.event.currentTarget.element, + this.gestureId, + SetGestureStateType.fail, + ); + return result; + } + + active() { + const result = this.stateManager.__SetGestureState( + this.event.currentTarget.element, + this.gestureId, + SetGestureStateType.active, + ); + return result; + } + + end() { + const result = this.stateManager.__SetGestureState( + this.event.currentTarget.element, + this.gestureId, + SetGestureStateType.end, + ); + return result; + } + + consumeGesture(shouldConsume: boolean) { + const result = this.stateManager.__ConsumeGesture( + this.event.currentTarget.element, + this.gestureId, + { consume: shouldConsume, inner: true }, + ); + return result; + } + + interceptGesture( + shouldIntercept: boolean, + ) { + return this.stateManager.__ConsumeGesture( + this.event.currentTarget.element, + this.gestureId, + { consume: shouldIntercept, inner: false }, + ); + } + } + + return cb(event as TEvent, new StateManager(id, nativeStateManager, event)); + }; +} + +abstract class BaseGesture< + TConfig extends BaseGestureConfig = BaseGestureConfig, + TEvent extends GestureChangeEvent = GestureChangeEvent, +> implements GestureKind { + abstract type: GestureTypeInner; + config: TConfig = { + enabled: true, + } as TConfig; + id: number; + simultaneousWith: BaseGesture[] = []; + waitFor: BaseGesture[] = []; + continueWith: BaseGesture[] = []; + execId: number; + callbacks: BaseGestureCallbacks = {}; + __isGesture: true = true; + + constructor(gesture?: BaseGesture) { + if (gesture) { + this.id = gesture.id; + this.execId = gesture.execId; + this.config = { ...gesture.config }; + this.simultaneousWith = [...gesture.simultaneousWith]; + this.waitFor = [...gesture.waitFor]; + this.continueWith = [...gesture.continueWith]; + this.callbacks = { ...gesture.callbacks }; + } else { + this.id = gestureID++; + this.execId = 0; + } + } + + updateConfig = (k: string, v: unknown): this => { + this.execId += 1; + (this.config as Record)[k] = v; + return this; + }; + + updateCallback = ( + k: keyof typeof this.callbacks, + cb: GestureCallback, + ): this => { + this.execId += 1; + // Wrapped callback is compatible with GestureCallback at runtime + this.callbacks[k] = wrapCallback( + cb, + this.id, + k, + ) as unknown as GestureCallback; + return this; + }; + + enabled = (enabled: boolean): this => { + return this.updateConfig('enabled', enabled); + }; + + // Gesture State Callbacks + onBegin = (cb: GestureCallback): this => { + return this.updateCallback('onBegin', cb); + }; + + onStart = (cb: GestureCallback): this => { + return this.updateCallback('onStart', cb); + }; + + onEnd = (cb: GestureCallback): this => { + return this.updateCallback('onEnd', cb); + }; + onTouchesDown = (cb: GestureCallback): this => { + return this.updateCallback('onTouchesDown', cb); + }; + onTouchesMove = (cb: GestureCallback): this => { + return this.updateCallback('onTouchesMove', cb); + }; + onTouchesUp = (cb: GestureCallback): this => { + return this.updateCallback('onTouchesUp', cb); + }; + onTouchesCancel = (cb: GestureCallback): this => { + return this.updateCallback('onTouchesCancel', cb); + }; + + externalWaitFor = (gesture: GestureKind): this => { + if (gesture === this) { + return this; + } + this.execId += 1; + if (gesture.type === GestureTypeInner.COMPOSED) { + this.waitFor = this.waitFor.concat(gesture.toGestureArray()); + } else { + this.waitFor.push( + gesture as BaseGesture, + ); + } + return this; + }; + + externalSimultaneous = (gesture: GestureKind): this => { + if (gesture === this) { + return this; + } + this.execId += 1; + if (gesture.type === GestureTypeInner.COMPOSED) { + this.simultaneousWith = this.simultaneousWith.concat( + gesture.toGestureArray(), + ); + } else { + this.simultaneousWith.push( + gesture as BaseGesture, + ); + } + return this; + }; + + externalContinueWith = (gesture: GestureKind): this => { + if (gesture === this) { + return this; + } + this.execId += 1; + if (gesture.type === GestureTypeInner.COMPOSED) { + this.continueWith = this.continueWith.concat(gesture.toGestureArray()); + } else { + this.continueWith.push( + gesture as BaseGesture, + ); + } + return this; + }; + + toGestureArray = (): BaseGesture[] => { + return [ + this as unknown as BaseGesture, + ]; + }; + + serialize = (): Record => { + const result = { + config: this.config, + id: this.id, + type: this.type, + simultaneousWith: this.simultaneousWith.map((gesture) => ({ + id: gesture.id, + })), + waitFor: this.waitFor.map((gesture) => ({ id: gesture.id })), + continueWith: this.continueWith.map((gesture) => ({ id: gesture.id })), + callbacks: this.callbacks, + __isSerialized: true, + }; + + return removeUndefined(result); + }; + + toJSON = (): Record => { + return this.serialize(); + }; + + clone = (): this => { + // Create new instance + const cloned = new (this.constructor as new(gesture?: this) => this)(this); + Object.assign(cloned, this); + + return cloned; + }; +} + +abstract class ContinuousGesture< + TConfig extends BaseGestureConfig = BaseGestureConfig, + TEvent extends GestureChangeEvent = GestureChangeEvent, +> extends BaseGesture { + override callbacks: + & BaseGestureCallbacks + & ContinuousGestureCallbacks = {}; + + override updateCallback = ( + k: keyof typeof this.callbacks, + cb: GestureCallback, + ): this => { + this.execId += 1; + // Wrapped callback is compatible with GestureCallback at runtime + this.callbacks[k] = wrapCallback( + cb, + this.id, + k, + ) as unknown as GestureCallback; + return this; + }; + + onUpdate = (cb: GestureCallback): this => { + return this.updateCallback('onUpdate', cb); + }; +} + +export { BaseGesture, ContinuousGesture }; +export type BaseGestureType = InstanceType; diff --git a/packages/lynx/gesture-runtime/src/composition.ts b/packages/lynx/gesture-runtime/src/composition.ts new file mode 100644 index 0000000000..291c10a383 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/composition.ts @@ -0,0 +1,170 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { BaseGesture } from './baseGesture.js'; +import type { + BaseGestureConfig, + GestureChangeEvent, + GestureKind, +} from './gestureInterface.js'; +import { GestureTypeInner } from './gestureInterface.js'; +import type { PanGesture } from './panGesture.js'; + +function extendRelation( + currentRelation: + | BaseGesture[] + | undefined, + extendWith: BaseGesture[], +) { + if (currentRelation === undefined) { + return [...extendWith]; + } else { + return [...currentRelation, ...extendWith]; + } +} + +class ComposedGesture implements GestureKind { + public gestures: GestureKind[] = []; + simultaneousWith: BaseGesture[] = []; + continueWith: BaseGesture[] = []; + waitFor: BaseGesture[] = []; + type: GestureTypeInner = GestureTypeInner.COMPOSED; + // ComposedGesture will be flatten, and should only be processed once + panProcessed = false; + __isGesture: true = true; + + constructor(...gestures: GestureKind[]) { + this.gestures = gestures; + this.prepare(); + } + + prepareSingleGesture = ( + gesture: GestureKind, + simultaneousGestures: BaseGesture[], + waitFor: BaseGesture[], + ): void => { + if (gesture instanceof BaseGesture) { + gesture.simultaneousWith = extendRelation( + gesture.simultaneousWith, + simultaneousGestures, + ); + gesture.waitFor = extendRelation(gesture.waitFor, waitFor); + } else if (gesture instanceof ComposedGesture) { + gesture.simultaneousWith = simultaneousGestures; + gesture.waitFor = waitFor; + gesture.prepare(); + } + }; + + prepare(): void { + for (const gesture of this.gestures) { + this.prepareSingleGesture(gesture, this.simultaneousWith, this.waitFor); + } + } + + /** + * If pan gesture has relationship with tap or longPress, its default minDistance should be overridden + */ + processPanDistance = (): void => { + if (this.panProcessed) { + return; + } + this.panProcessed = true; + + const gestures = this.gestures.flatMap((gesture) => + gesture.toGestureArray() + ); + + let hasTap = false; + const panGestureArr: PanGesture[] = []; + gestures.forEach((gesture) => { + if ( + gesture.type === GestureTypeInner.LONGPRESS + || gesture.type === GestureTypeInner.TAP + ) { + hasTap = true; + } else if (gesture.type === GestureTypeInner.PAN) { + panGestureArr.push(gesture as unknown as PanGesture); + } + }); + + if (hasTap) { + panGestureArr.forEach((gesture) => { + gesture.overrideDefaultMinDistance(); + }); + } + }; + + toGestureArray = (): BaseGesture[] => { + return this.gestures.flatMap((gesture) => gesture.toGestureArray()); + }; + + serialize = (): Record => { + return { + type: this.type, + gestures: this.gestures.map((gesture) => gesture.serialize()), + __isSerialized: true, + }; + }; + + toJSON = (): Record => { + return this.serialize(); + }; +} + +class SimultaneousGesture extends ComposedGesture { + override prepare(): void { + // this piece of magic works something like this: + // for every gesture in the array + const simultaneousArrays = this.gestures.map((gesture) => + // we take the array it's in + this.gestures + // and make a copy without it + .filter((x) => x !== gesture) + // then we flatmap the result to get list of raw (not composed) gestures + // this way we don't make the gestures simultaneous with themselves, which is + // important when the gesture is `ExclusiveGesture` - we don't want to make + // exclusive gestures simultaneous + .flatMap((x) => x.toGestureArray()) + ); + + this.gestures.forEach((gesture, index) => { + if (gesture) { + this.prepareSingleGesture( + gesture, + /* c8 ignore next -- Defensive coding: simultaneousArrays always matches gestures length */ + simultaneousArrays[index] ?? [], + this.waitFor, + ); + } + }); + } +} + +class ExclusiveGesture extends ComposedGesture { + override prepare(): void { + // transforms the array of gestures into array of grouped raw (not composed) gestures + // i.e. [gesture1, gesture2, ComposedGesture(gesture3, gesture4)] -> [[gesture1], [gesture2], [gesture3, gesture4]] + const gestureArrays = this.gestures.map((gesture) => + gesture.toGestureArray() + ); + + let requireToFail: BaseGesture[] = []; + + this.gestures.forEach((gesture, index) => { + this.prepareSingleGesture( + gesture, + this.simultaneousWith, + this.waitFor.concat(requireToFail), + ); + + // every group gets to wait for all groups before it + + requireToFail = requireToFail.concat(gestureArrays[index]!); + }); + } +} + +class RaceGesture extends ComposedGesture {} + +export { ComposedGesture, SimultaneousGesture, ExclusiveGesture, RaceGesture }; diff --git a/packages/lynx/gesture-runtime/src/defaultScrollGesture.ts b/packages/lynx/gesture-runtime/src/defaultScrollGesture.ts new file mode 100644 index 0000000000..fa8eec9213 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/defaultScrollGesture.ts @@ -0,0 +1,24 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { ContinuousGesture } from './baseGesture.js'; +import type { + DefaultGestureChangeEvent, + DefaultGestureConfig, +} from './gestureInterface.js'; +import { GestureTypeInner } from './gestureInterface.js'; + +export class DefaultScrollGesture + extends ContinuousGesture +{ + type: GestureTypeInner = GestureTypeInner.DEFAULT; + + override config: DefaultGestureConfig = { + enabled: true, + tapSlop: 3, // default tap slop + }; + + tapSlop = (tapSlop: number): this => { + return this.updateConfig('tapSlop', tapSlop); + }; +} diff --git a/packages/lynx/gesture-runtime/src/env_types/global.d.ts b/packages/lynx/gesture-runtime/src/env_types/global.d.ts new file mode 100644 index 0000000000..788117d50b --- /dev/null +++ b/packages/lynx/gesture-runtime/src/env_types/global.d.ts @@ -0,0 +1,30 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +declare class FiberElement {} + +declare function __SetGestureDetector( + node: FiberElement, + id: number, + type: number, + config: Record, + relationMap: Record, +): void; +declare function __RemoveGestureDetector(node: FiberElement, id: number): void; + +declare function __FlushElementTree(element?: FiberElement): void; + +declare global { + var runWorklet: (worklet: unknown, data: unknown) => void; +} + +// Internal augmentation - not exported to users +declare module '@lynx-js/types' { + // biome-ignore lint/style/noNamespace: Augmenting external namespace + namespace MainThread { + interface Element { + element: FiberElement; + } + } +} diff --git a/packages/lynx/gesture-runtime/src/flingGesture.ts b/packages/lynx/gesture-runtime/src/flingGesture.ts new file mode 100644 index 0000000000..cc27690688 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/flingGesture.ts @@ -0,0 +1,22 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { ContinuousGesture } from './baseGesture.js'; +import type { + FlingGestureChangeEvent, + FlingGestureConfig, +} from './gestureInterface.js'; +import { GestureTypeInner } from './gestureInterface.js'; + +export interface FlingGestureChangeEventPayload { + changeX: number; + changeY: number; +} + +class FlingGesture + extends ContinuousGesture +{ + type: GestureTypeInner = GestureTypeInner.FLING; +} + +export { FlingGesture }; diff --git a/packages/lynx/gesture-runtime/src/gestureInterface.ts b/packages/lynx/gesture-runtime/src/gestureInterface.ts new file mode 100644 index 0000000000..710ed93846 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/gestureInterface.ts @@ -0,0 +1,302 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { MainThread } from '@lynx-js/types'; + +import type { BaseGesture } from './baseGesture.js'; + +export interface GestureKindSerialized { + type: GestureTypeInner; + waitFor: number[]; + continueWith: number[]; + simultaneousWith: number[]; + __isSerialized: true; +} + +export interface GestureKind { + __isGesture: true; + type: GestureTypeInner; + waitFor: GestureKind[]; + continueWith: GestureKind[]; + simultaneousWith: GestureKind[]; + toGestureArray: () => BaseGesture[]; + serialize: () => Record; +} + +/** + * Base gesture change event interface. + * All gesture-specific events extend this interface. + */ +export interface GestureChangeEvent { + target: MainThread.Element; + currentTarget: MainThread.Element; + params: { + pageX: number; + pageY: number; + timestamp: number; + type: string; + clientX: number; + y: number; + x: number; + clientY: number; + }; +} + +/** + * Pan gesture change event with translation and velocity information. + */ +export interface PanGestureChangeEvent extends GestureChangeEvent { + params: GestureChangeEvent['params'] & { + scrollX: number; + scrollY: number; + isAtStart: boolean; + isAtEnd: boolean; + }; +} + +/** + * Tap gesture change event with tap location and count information. + */ +export interface TapGestureChangeEvent extends GestureChangeEvent {} + +/** + * Long press gesture change event with press location and duration information. + */ +export interface LongPressGestureChangeEvent extends GestureChangeEvent {} + +/** + * Fling gesture change event with fling location and velocity information. + */ +export interface FlingGestureChangeEvent extends GestureChangeEvent { + params: GestureChangeEvent['params'] & { + scrollX: number; + scrollY: number; + deltaX: number; + deltaY: number; + isAtStart: boolean; + isAtEnd: boolean; + }; +} + +/** + * Default gesture change event. + * Uses the base GestureChangeEvent without additional properties. + */ +export interface DefaultGestureChangeEvent extends GestureChangeEvent { + params: GestureChangeEvent['params'] & { + scrollX: number; + scrollY: number; + deltaX: number; + deltaY: number; + isAtStart: boolean; + isAtEnd: boolean; + }; +} + +/** + * Native gesture change event. + * Uses the base GestureChangeEvent without additional properties. + */ +export interface NativeGestureChangeEvent extends GestureChangeEvent { + // Uses base event properties +} + +export enum SetGestureStateType { + active = 1, + fail = 2, + end = 3, +} + +export interface ConsumeGestureParams { + inner?: boolean; + consume?: boolean; +} + +export enum LynxNativeIOSGestureRecognizer { + Base = 'Base', + Pan = 'Pan', + LongPress = 'LongPress', + Tap = 'Tap', + Swipe = 'Swipe', + ScreenEdgePan = 'ScreenEdgePan', + Hover = 'Hover', + Rotation = 'Rotation', + Pinch = 'Pinch', +} + +export interface InterceptGestureOptions { + tag: number; + class: string; + type: LynxNativeIOSGestureRecognizer; +} + +/** + * Used internally, not meant to be public + */ +export interface InternalStateManager { + active: (id: number) => void; + fail: (id: number) => void; + end: (id: number) => void; + scrollBy: (deltaX: number, deltaY: number) => number[]; + __SetGestureState: ( + dom: FiberElement, + id: number, + type: SetGestureStateType, + ) => void; + __ConsumeGesture: ( + dom: FiberElement, + id: number, + params: ConsumeGestureParams, + ) => void; +} + +export interface StateManager { + active: () => void; + fail: () => void; + end: () => void; + consumeGesture: (shouldConsume: boolean) => void; + interceptGesture: (shouldIntercept: boolean) => void; +} + +export enum GestureTypeInner { + COMPOSED = -1, + PAN = 0, + FLING = 1, + DEFAULT = 2, + TAP = 3, + LONGPRESS = 4, + ROTATION = 5, + PINCH = 6, + NATIVE = 7, +} + +/** + * Generic gesture callback type that receives typed events. + * @template TEvent - The specific gesture event type + */ +export type GestureCallback< + TEvent extends GestureChangeEvent = GestureChangeEvent, +> = ( + event: TEvent, + stateManager: StateManager, +) => void; + +export type GestureControlCallback = ( + event: GestureChangeEvent, + stateManager: StateManager, +) => void; + +export type GestureControlInternalCallback = ( + event: GestureChangeEvent, + stateManager: InternalStateManager, +) => void; + +/** + * Base configuration interface for all gestures. + * All gesture-specific configurations extend this interface. + */ +export interface BaseGestureConfig extends Record { + /** Whether the gesture is enabled. Defaults to true. */ + enabled?: boolean; +} + +/** + * Configuration interface for PanGesture. + * Pan gesture recognizes pan (dragging) gestures. + */ +export interface PanGestureConfig extends BaseGestureConfig { + /** Minimum distance (in points) that must be traveled before the gesture activates. Defaults to 0. */ + minDistance?: number; + /** Active offset for X axis */ + activeOffsetX?: number; + /** Active offset for Y axis */ + activeOffsetY?: number; + /** Fail offset for X axis */ + failOffsetX?: number; + /** Fail offset for Y axis */ + failOffsetY?: number; +} + +/** + * Configuration interface for TapGesture. + * Tap gesture recognizes tap gestures. + */ +export interface TapGestureConfig extends BaseGestureConfig { + /** Maximum duration (in milliseconds) for a tap. Defaults to 500ms. */ + maxDuration?: number; + /** Maximum distance (in points) the touch can move and still be considered a tap. Defaults to 10. */ + maxDistance?: number; + /** Number of taps required. Defaults to 1. */ + numberOfTaps?: number; +} + +/** + * Configuration interface for LongPressGesture. + * Long press gesture recognizes long press gestures. + */ +export interface LongPressGestureConfig extends BaseGestureConfig { + /** Minimum duration (in milliseconds) for a long press. Defaults to 500ms. */ + minDuration?: number; + /** Maximum distance (in points) the touch can move and still be considered a long press. Defaults to 10. */ + maxDistance?: number; +} + +/** + * Configuration interface for FlingGesture. + * Fling gesture recognizes fling (swipe) gestures. + */ +export interface FlingGestureConfig extends BaseGestureConfig { + /** Direction of the fling gesture */ + direction?: 'up' | 'down' | 'left' | 'right'; + /** Number of pointers (fingers) required. Defaults to 1. */ + numberOfPointers?: number; +} + +/** + * Configuration interface for DefaultGesture. + * Default gesture is a basic gesture handler. + */ +export interface DefaultGestureConfig extends BaseGestureConfig { + /** Tap slop value (in points). Defaults to 3. */ + tapSlop?: number; +} + +/** + * Configuration interface for NativeGesture. + * Native gesture uses platform-specific gesture recognizers. + */ +export interface NativeGestureConfig extends BaseGestureConfig { + // Native gesture uses base config only +} + +/** + * Generic base gesture callbacks interface. + * @template TEvent - The specific gesture event type + */ +export interface BaseGestureCallbacks< + TEvent extends GestureChangeEvent = GestureChangeEvent, +> { + onBegin?: GestureCallback; + onStart?: GestureCallback; + onEnd?: GestureCallback; + onTouchesDown?: GestureCallback; + onTouchesMove?: GestureCallback; + onTouchesUp?: GestureCallback; + onTouchesCancel?: GestureCallback; +} + +/** + * Generic continuous gesture callbacks interface. + * @template TEvent - The specific gesture event type + */ +export interface ContinuousGestureCallbacks< + TEvent extends GestureChangeEvent = GestureChangeEvent, +> { + onUpdate?: GestureCallback; +} + +declare module '@lynx-js/types' { + interface StandardProps { + 'main-thread:gesture'?: GestureKind; + } +} diff --git a/packages/lynx/gesture-runtime/src/index.ts b/packages/lynx/gesture-runtime/src/index.ts new file mode 100644 index 0000000000..7f8ed132ba --- /dev/null +++ b/packages/lynx/gesture-runtime/src/index.ts @@ -0,0 +1,48 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { BaseGesture } from './baseGesture.js'; +import { + ComposedGesture, + ExclusiveGesture, + RaceGesture, + SimultaneousGesture, +} from './composition.js'; +import { DefaultScrollGesture } from './defaultScrollGesture.js'; +import { FlingGesture } from './flingGesture.js'; +import type { GestureKind } from './gestureInterface.js'; +import { LongPressGesture } from './longPressGesture.js'; +import { PanGesture } from './panGesture.js'; +import { TapGesture } from './tapGesture.js'; +import { useGesture } from './useGesture.js'; + +const GestureEntry = { + Simultaneous(...gestures: GestureKind[]): GestureKind { + return new SimultaneousGesture(...gestures); + }, + + Race(...gestures: GestureKind[]): GestureKind { + return new RaceGesture(...gestures); + }, + + Exclusive(...gestures: GestureKind[]): GestureKind { + return new ExclusiveGesture(...gestures); + }, +}; + +export { GestureEntry as Gesture }; +export * from './gestureInterface.js'; +export { + BaseGesture, + SimultaneousGesture, + ComposedGesture, + ExclusiveGesture, + DefaultScrollGesture, + FlingGesture, + PanGesture, + TapGesture, + LongPressGesture, +}; + +export { useGesture }; diff --git a/packages/lynx/gesture-runtime/src/longPressGesture.ts b/packages/lynx/gesture-runtime/src/longPressGesture.ts new file mode 100644 index 0000000000..c7f839a548 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/longPressGesture.ts @@ -0,0 +1,31 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { BaseGesture } from './baseGesture.js'; +import type { + LongPressGestureChangeEvent, + LongPressGestureConfig, +} from './gestureInterface.js'; +import { GestureTypeInner } from './gestureInterface.js'; +import { DEFAULT_DISTANCE, DEFAULT_LONGPRESS_DURATION } from './utils/const.js'; + +class LongPressGesture + extends BaseGesture +{ + override config: LongPressGestureConfig = { + enabled: true, + minDuration: DEFAULT_LONGPRESS_DURATION, + maxDistance: DEFAULT_DISTANCE, + }; + type: GestureTypeInner = GestureTypeInner.LONGPRESS; + + minDuration = (duration: number): this => { + return this.updateConfig('minDuration', duration); + }; + + maxDistance = (distance: number): this => { + return this.updateConfig('maxDistance', distance); + }; +} + +export { LongPressGesture }; diff --git a/packages/lynx/gesture-runtime/src/panGesture.ts b/packages/lynx/gesture-runtime/src/panGesture.ts new file mode 100644 index 0000000000..486b7e0c75 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/panGesture.ts @@ -0,0 +1,49 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { ContinuousGesture } from './baseGesture.js'; +import type { + PanGestureChangeEvent, + PanGestureConfig, +} from './gestureInterface.js'; +import { GestureTypeInner } from './gestureInterface.js'; +import { DEFAULT_DISTANCE, DEFAULT_PAN_DISTANCE } from './utils/const.js'; + +export interface PanGestureChangeEventPayload { + changeX: number; + changeY: number; +} + +class PanGesture + extends ContinuousGesture +{ + type: GestureTypeInner = GestureTypeInner.PAN; + distanceSet = false; + + override config: PanGestureConfig = { + enabled: true, + minDistance: DEFAULT_PAN_DISTANCE, + }; + + minDistance = (distance: number): this => { + // We need to know whether distance is set by user or it's default value + // So that we can get to know override it or not + if (distance === undefined) { + this.distanceSet = false; + } else { + this.distanceSet = true; + } + return this.updateConfig('minDistance', distance); + }; + + overrideDefaultMinDistance = (): this => { + if (this.distanceSet) { + return this; + } else { + this.updateConfig('minDistance', DEFAULT_DISTANCE); + } + return this; + }; +} + +export { PanGesture }; diff --git a/packages/lynx/gesture-runtime/src/tapGesture.ts b/packages/lynx/gesture-runtime/src/tapGesture.ts new file mode 100644 index 0000000000..a3ce017eb4 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/tapGesture.ts @@ -0,0 +1,33 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { BaseGesture } from './baseGesture.js'; +import type { + TapGestureChangeEvent, + TapGestureConfig, +} from './gestureInterface.js'; +import { GestureTypeInner } from './gestureInterface.js'; +import { DEFAULT_DISTANCE, DEFAULT_LONGPRESS_DURATION } from './utils/const.js'; + +class TapGesture extends BaseGesture { + type: GestureTypeInner = GestureTypeInner.TAP; + override config: TapGestureConfig = { + enabled: true, + maxDuration: DEFAULT_LONGPRESS_DURATION, + maxDistance: DEFAULT_DISTANCE, + }; + + maxDuration = (duration: number): this => { + return this.updateConfig('maxDuration', duration); + }; + + maxDistance = (distance: number): this => { + return this.updateConfig('maxDistance', distance); + }; + + numberOfTaps = (count: number): this => { + return this.updateConfig('numberOfTaps', count); + }; +} + +export { TapGesture }; diff --git a/packages/lynx/gesture-runtime/src/useGesture.ts b/packages/lynx/gesture-runtime/src/useGesture.ts new file mode 100644 index 0000000000..5608dc39f5 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/useGesture.ts @@ -0,0 +1,36 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { useRef } from '@lynx-js/react'; + +import type { DefaultGesture } from './defaultGesture.js'; +import type { FlingGesture } from './flingGesture.js'; +import type { LongPressGesture } from './longPressGesture.js'; +import type { NativeGesture } from './nativeGesture.js'; +import type { PanGesture } from './panGesture.js'; +import type { TapGesture } from './tapGesture.js'; + +type IBasicGestures = + | PanGesture + | FlingGesture + | DefaultGesture + | TapGesture + | LongPressGesture + | NativeGesture; + +function useGesture( + GestureConstructor: new() => T, +): T { + const gestureRef = useRef(new GestureConstructor()); + const lastExecIdRef = useRef(gestureRef.current.execId); + + if (lastExecIdRef.current !== gestureRef.current.execId) { + lastExecIdRef.current = gestureRef.current.execId; + const cloned = gestureRef.current.clone() as T; + gestureRef.current = cloned; + } + + return gestureRef.current; +} + +export { useGesture }; diff --git a/packages/lynx/gesture-runtime/src/utils/const.ts b/packages/lynx/gesture-runtime/src/utils/const.ts new file mode 100644 index 0000000000..d6d5d60d41 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/utils/const.ts @@ -0,0 +1,6 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const DEFAULT_DISTANCE = 10; +export const DEFAULT_PAN_DISTANCE = 0; +export const DEFAULT_LONGPRESS_DURATION = 500; diff --git a/packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts b/packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts new file mode 100644 index 0000000000..5f629570f7 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts @@ -0,0 +1,14 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export function isWorkletObj(obj: unknown): boolean { + if (__MAIN_THREAD__) { + return true; + } + + if (!obj) { + return false; + } + return typeof obj === 'object' && '_wkltId' in obj; +} diff --git a/packages/lynx/gesture-runtime/src/utils/removeUndefined.ts b/packages/lynx/gesture-runtime/src/utils/removeUndefined.ts new file mode 100644 index 0000000000..21d377ac69 --- /dev/null +++ b/packages/lynx/gesture-runtime/src/utils/removeUndefined.ts @@ -0,0 +1,10 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export function removeUndefined( + obj: Record, +): Record { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value !== undefined), + ); +} diff --git a/packages/lynx/gesture-runtime/tsconfig.build.json b/packages/lynx/gesture-runtime/tsconfig.build.json new file mode 100644 index 0000000000..b9a1db2ffa --- /dev/null +++ b/packages/lynx/gesture-runtime/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "stripInternal": true, + "target": "ESNext", + "lib": ["es2021"], + "module": "Node16", + "moduleResolution": "Node16", + "resolveJsonModule": true, + "composite": true, + "jsx": "react-jsx", + "jsxImportSource": "@lynx-js/react", + "rootDir": "src", + }, + "include": ["src"], + "references": [ + { "path": "../../react" }, + ], +} diff --git a/packages/lynx/gesture-runtime/tsconfig.json b/packages/lynx/gesture-runtime/tsconfig.json new file mode 100644 index 0000000000..e13dabad5d --- /dev/null +++ b/packages/lynx/gesture-runtime/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.build.json" }, + { "path": "./tsconfig.test.json" }, + ], +} diff --git a/packages/lynx/gesture-runtime/tsconfig.test.json b/packages/lynx/gesture-runtime/tsconfig.test.json new file mode 100644 index 0000000000..7b11fa0bdb --- /dev/null +++ b/packages/lynx/gesture-runtime/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "__test__"] +} diff --git a/packages/lynx/gesture-runtime/vitest.config.ts b/packages/lynx/gesture-runtime/vitest.config.ts new file mode 100644 index 0000000000..3e8c26be58 --- /dev/null +++ b/packages/lynx/gesture-runtime/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; + +const defaultConfig = await createVitestConfig(); +const config = defineConfig({ + test: { + name: 'lynx/gesture-runtime', + setupFiles: ['__test__/utils/setup.ts'], + coverage: { + include: ['src/**'], + }, + include: ['__test__/**/*.test.{js,jsx,ts,tsx}'], + exclude: ['__test__/utils/**'], + }, +}); + +export default mergeConfig(defaultConfig, config); diff --git a/packages/lynx/tsconfig.json b/packages/lynx/tsconfig.json new file mode 100644 index 0000000000..c8cb2008b6 --- /dev/null +++ b/packages/lynx/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + }, + "references": [ + /** packages-start */ + { "path": "./gesture-runtime/tsconfig.build.json" }, + /** packages-end */ + ], + "include": [], +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 071c5df325..e37fa0985b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,21 @@ importers: specifier: ^8.8.4 version: 8.8.5 + packages/lynx/gesture-runtime: + devDependencies: + '@lynx-js/react': + specifier: workspace:* + version: link:../../react + '@lynx-js/types': + specifier: 3.4.11 + version: 3.4.11 + '@testing-library/jest-dom': + specifier: ^6.9.0 + version: 6.9.1 + rsbuild-plugin-publint: + specifier: 0.3.3 + version: 0.3.3(@rsbuild/core@1.6.7) + packages/mcp-servers/devtool-mcp-server: dependencies: '@lynx-js/debug-router-connector': @@ -408,7 +423,7 @@ importers: version: 10.4.1 '@testing-library/jest-dom': specifier: ^6.9.0 - version: 6.9.0 + version: 6.9.1 packages/react/transform: devDependencies: @@ -766,7 +781,7 @@ importers: version: link:../../../rspeedy/core '@testing-library/jest-dom': specifier: ^6.9.0 - version: 6.9.0 + version: 6.9.1 packages/testing-library/examples/react-compiler: dependencies: @@ -803,7 +818,7 @@ importers: version: 3.4.11 '@testing-library/jest-dom': specifier: ^6.9.0 - version: 6.9.0 + version: 6.9.1 '@types/react': specifier: ^18.3.25 version: 18.3.25 @@ -818,7 +833,7 @@ importers: devDependencies: '@testing-library/jest-dom': specifier: ^6.9.0 - version: 6.9.0 + version: 6.9.1 '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 @@ -3741,8 +3756,8 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.9.0': - resolution: {integrity: sha512-QHdxYMJ0YPGKYofMc6zYvo7LOViVhdc6nPg/OtM2cf9MQrwEcTxFCs7d/GJ5eSyPkHzOiBkc/KfLdFJBHzldtQ==} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} '@trysound/sax@0.2.0': @@ -12410,7 +12425,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.0': + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.4.2 aria-query: 5.3.0 diff --git a/tsconfig.json b/tsconfig.json index 02fbf24466..c44f6ac69b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -124,6 +124,9 @@ }, { "path": "./packages/third-party" + }, + { + "path": "./packages/lynx" } ], "include": [] From 533b90fdf220d7fa68d80612b40203a980f134c8 Mon Sep 17 00:00:00 2001 From: f0rdream <14049186+f0rdream@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:57:02 +0800 Subject: [PATCH 2/3] chore: gesture-runtime testing --- packages/lynx/gesture-runtime/README.md | 4 ---- .../__test__/gesture-plain.test.ts | 2 +- .../__test__/gesture-react.test.tsx | 2 +- .../__test__/utils/callback.ts | 11 ++++++++-- packages/lynx/gesture-runtime/package.json | 2 +- .../lynx/gesture-runtime/src/baseGesture.ts | 2 +- packages/lynx/gesture-runtime/turbo.jsonc | 20 +++++++++++++++++++ vitest.config.ts | 1 + 8 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 packages/lynx/gesture-runtime/turbo.jsonc diff --git a/packages/lynx/gesture-runtime/README.md b/packages/lynx/gesture-runtime/README.md index 02eb265321..8612208b82 100644 --- a/packages/lynx/gesture-runtime/README.md +++ b/packages/lynx/gesture-runtime/README.md @@ -19,7 +19,3 @@ export default function Example() { return ; } ``` - -## Docs - -- See the dedicated guide website for full documentation and API details. diff --git a/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts index b764793f59..41df7b2d71 100644 --- a/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts +++ b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts @@ -61,7 +61,7 @@ describe('create gesture', () => { // Non Main Thread Callback }) ).toThrow( - `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`, + `Gesture callback for 'onUpdate' must be a main thread function`, ); }); diff --git a/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx index 0c5a468ae5..69afbf5be2 100644 --- a/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx +++ b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx @@ -79,7 +79,7 @@ describe('create gesture', () => { // Non Main Thread Callback }) ).toThrow( - `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`, + `Gesture callback for 'onUpdate' must be a main thread function`, ); }); diff --git a/packages/lynx/gesture-runtime/__test__/utils/callback.ts b/packages/lynx/gesture-runtime/__test__/utils/callback.ts index 3036a7057e..0c42888bb1 100644 --- a/packages/lynx/gesture-runtime/__test__/utils/callback.ts +++ b/packages/lynx/gesture-runtime/__test__/utils/callback.ts @@ -8,7 +8,14 @@ import type { ConsumeGestureParams, SetGestureStateType, } from '../../src/gestureInterface.js'; -import type { GestureConfig } from '../../src/processGesture.js'; + +interface ProcessesGestureConfig { + callbacks: { + name: string; + callback: Worklet; + }[]; + config?: Record; +} export class MockGestureManager { __SetGestureState: Mock< @@ -19,7 +26,7 @@ export class MockGestureManager { > = vi.fn((dom: any, id: number, params: ConsumeGestureParams) => {}); } -function getCallback(config: GestureConfig, method: string) { +function getCallback(config: ProcessesGestureConfig, method: string) { if (config.callbacks) { return config.callbacks.find((callback) => callback.name === method); } else { diff --git a/packages/lynx/gesture-runtime/package.json b/packages/lynx/gesture-runtime/package.json index 9c89042973..ac2869004a 100644 --- a/packages/lynx/gesture-runtime/package.json +++ b/packages/lynx/gesture-runtime/package.json @@ -2,7 +2,7 @@ "name": "@lynx-js/gesture-runtime", "version": "2.0.0", "description": "Lynx Gesture", - "license": "ISC", + "license": "Apache-2.0", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/lynx/gesture-runtime/src/baseGesture.ts b/packages/lynx/gesture-runtime/src/baseGesture.ts index 0b59a405a8..5c4b23b0b2 100644 --- a/packages/lynx/gesture-runtime/src/baseGesture.ts +++ b/packages/lynx/gesture-runtime/src/baseGesture.ts @@ -29,7 +29,7 @@ function wrapCallback( ): WrappedCallback { if (cb && !isWorkletObj(cb)) { throw new Error( - `Gesture Callback Must be a Main Thread Function, check callback of ${name}'s callback`, + `Gesture callback for '${name}' must be a main thread function.`, ); } diff --git a/packages/lynx/gesture-runtime/turbo.jsonc b/packages/lynx/gesture-runtime/turbo.jsonc new file mode 100644 index 0000000000..72c6dacfba --- /dev/null +++ b/packages/lynx/gesture-runtime/turbo.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": [ + "@lynx-js/react#build", + ], + "inputs": [ + "$TURBO_ROOT$/tsconfig.json", + "package.json", + "src/**", + "rslib.config.ts", + ], + "outputs": [ + "dist", + ], + }, + }, +} diff --git a/vitest.config.ts b/vitest.config.ts index e8f3f0e115..98c36891b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -78,6 +78,7 @@ export default defineConfig({ 'packages/use-sync-external-store/vitest.config.ts', 'packages/web-platform/*/vitest.config.ts', 'packages/webpack/*/vitest.config.ts', + 'packages/lynx/gesture-runtime/vitest.config.ts', ], }, }); From 9e03d49b7b92ff138cf738880895d0403b42ac89 Mon Sep 17 00:00:00 2001 From: f0rdream <14049186+f0rdream@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:33:18 +0800 Subject: [PATCH 3/3] chore: resolve conflicts --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5904a45802..23b39b6157 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,7 +325,7 @@ importers: version: 6.9.1 rsbuild-plugin-publint: specifier: 0.3.3 - version: 0.3.3(@rsbuild/core@1.6.9) + version: 0.3.3(@rsbuild/core@1.6.13) packages/mcp-servers/devtool-mcp-server: dependencies: