diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e863002..89c63b2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,14 +1,26 @@ name: Checks -on: push +on: + push: + branches: + - master + pull_request: + jobs: - build: + checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install modules - run: yarn - - name: Run tsc + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'yarn' + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Run TypeScript run: yarn run typescript - name: Run ESLint run: yarn run lint + - name: Build + run: yarn build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d610bab --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + release: + types: [published] + +jobs: + publish: + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + cache: 'yarn' + + - name: Verify npm version + run: npm --version + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Publish to npm + # --access public: Scoped packages (@openspacelabs/*) default to private, this makes it public + # id-token: write (above) enables --provenance for signed attestations, not registry auth + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 9da6239..a53e88a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ npm-debug.log yarn-debug.log yarn-error.log +# Build output +/lib/ + # BUCK buck-out/ \.buckd/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c29109..44da1b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,17 +76,30 @@ Our pre-commit hooks verify that your commit message matches this format when co We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. -Our pre-commit hooks verify that the linter and tests pass when committing. +Our pre-commit hooks verify that the linter and type checks pass when committing. + +### Build Process + +The library source code in `src/` is built to `lib/` for distribution. The `lib/` directory is not tracked in git. + +- **Local development**: No build needed - the example app reads directly from `src/` +- **Pull requests**: CI automatically builds to verify there are no build errors +- **Releases**: GitHub Actions automatically builds and publishes to npm + +You don't need to run build commands locally unless testing the built output. ### Publishing to npm -We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. +We use [release-it](https://github.com/release-it/release-it) to create releases and [GitHub Actions](.github/workflows/release.yml) to build and publish to npm. -To publish new versions, run the following: +To publish new versions: -```sh -yarn release -``` +1. Run `yarn release` locally (creates git tag and GitHub release) +2. GitHub Actions automatically builds and publishes to npm + +Only maintainers with permission to create releases can publish. The repository must have an `NPM_TOKEN` secret configured (Settings → Secrets and variables → Actions) for CI to publish to npm. + +> **Note:** GitHub pre-releases (e.g. `v3.0.0-beta.1`) intentionally skip the npm publish step to prevent beta versions from being tagged as `latest`. The CI job will show as "Skipped" — this is expected. To publish a pre-release manually, build locally and run `npm publish --tag beta --access public`. ### Scripts diff --git a/README.md b/README.md index e9980da..f64c307 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ These options can be used to limit and change the zoom behavior. | zoomStep | number | How much zoom should be applied on double tap | 0.5 | | pinchToZoomInSensitivity | number | the level of resistance (sensitivity) to zoom in (0 - 10) - higher is less sensitive | 3 | | pinchToZoomOutSensitivity | number | the level of resistance (sensitivity) to zoom out (0 - 10) - higher is less sensitive | 1 | -| movementSensibility | number | how resistant should shifting the view around be? (0.5 - 5) - higher is less sensitive | 1.9 | +| movementSensitivity | number | how resistant should shifting the view around be? (0.5 - 5) - higher is less sensitive | 1.9 | | initialOffsetX | number | The horizontal offset the image should start at | 0 | | initialOffsetY | number | The vertical offset the image should start at | 0 | | contentHeight | number | Specify if you want to treat the height of the **centered** content inside the zoom subject as the zoom subject's height | undefined | @@ -182,19 +182,15 @@ These optional props can be used to keep a "static" pin in the centre of the scr These events can be used to work with data after specific events. -| name | description | params | expected return | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| onTransform | Will be called when the transformation configuration (zoom level and offset) changes | zoomableViewEventObject | void | -| onDoubleTapBefore | Will be called at the start of a double tap | event, gestureState, zoomableViewEventObject | void | -| onDoubleTapAfter | Will be called at the end of a double tap | event, gestureState, zoomableViewEventObject | void | -| onShiftingBefore | Will be called when user taps and moves the view, but before our view movement work kicks in (so this is the place to interrupt movement, if you need to) | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the shift, otherwise it will | -| onShiftingAfter | Will be called when user taps and moves the view, but after the values have changed already | event, gestureState, zoomableViewEventObject | void | -| onShiftingEnd | Will be called when user stops a tap and move gesture | event, gestureState, zoomableViewEventObject | void | -| onZoomBefore | Will be called while the user pinches the screen, but before our zoom work kicks in (so this is the place to interrupt zooming, if you need to) | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the pinch, otherwise it will | -| onZoomAfter | Will be called while the user pinches the screen, but after the values have changed already | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the pinch, otherwise it will | -| onZoomEnd | Will be called after pinchzooming has ended | event, gestureState, zoomableViewEventObject | {boolean} if this returns true, ZoomableView will not process the pinch, otherwise it will | -| onLongPress | Will be called after the user pressed on the image for a while | event, gestureState | void | -| onLayout | Like `View`'s `onLayout`, but different in that it syncs with this component's internal state and returns a fake sythentic event | Like `View`'s `onLayout` but the synthetic event is fake | void | +| name | description | params | expected return | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------- | +| onTransform | Will be called when the transformation configuration (zoom level and offset) changes | zoomableViewEventObject | void | +| onDoubleTapBefore | Will be called at the start of a double tap | event, zoomableViewEventObject | void | +| onDoubleTapAfter | Will be called at the end of a double tap | event, zoomableViewEventObject | void | +| onShiftingEnd | Will be called when user stops a tap and move gesture | event, zoomableViewEventObject | void | +| onZoomEnd | Will be called after pinchzooming has ended | event, zoomableViewEventObject | void | +| onLongPress | Will be called after the user pressed on the image for a while | event | void | +| onLayout | Like `View`'s `onLayout`, but different in that it syncs with this component's internal state and returns a fake sythentic event | Like `View`'s `onLayout` but the synthetic event is fake | void | #### Methods @@ -259,17 +255,15 @@ export default function App() { #### Pan Responder Hooks -Sometimes you need to change deeper level behavior, so we prepared these panresponder hooks for you. +`react-native-gesture-handler` is now used instead of the built-in PanResponder. As such, we have removed some hooks that +are no longer supported and made the rest backward compatible. -| name | description | params | expected return | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------- | -| onStartShouldSetPanResponder | description | event, gestureState, zoomableViewEventObject, baseComponentResult | {boolean} whether panresponder should be set or not | -| onPanResponderGrant | description | event, gestureState, zoomableViewEventObject | void | -| onPanResponderEnd | Will be called when gesture ends (more accurately, on pan responder "release") | event, gestureState, zoomableViewEventObject | void | -| onPanResponderTerminate | Will be called when the gesture is force-interrupted by another handler | event, gestureState, zoomableViewEventObject | void | -| onPanResponderTerminationRequest | Callback asking whether the gesture should be interrupted by another handler (**iOS only** due to https://github.com/facebook/react-native/issues/27778, https://github.com/facebook/react-native/issues/5696, ...) | event, gestureState, zoomableViewEventObject | void | -| onPanResponderMove | Will be called when user moves while touching | event, gestureState, zoomableViewEventObject | void | -| onShouldBlockNativeResponder | Returns whether this component should block native components from becoming the JS responder | event, gestureState, zoomableViewEventObject | boolean | +| name | description | params | expected return | +| ------------------------- | ------------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------- | +| onPanResponderGrant | description | event, zoomableViewEventObject | void | +| onPanResponderEnd | Will be called when gesture ends (more accurately, on pan responder "release") | event, zoomableViewEventObject | void | +| onPanResponderTerminate | Will be called when the gesture is force-interrupted by another handler | event, zoomableViewEventObject | void | +| onPanResponderMoveWorklet | Will be called when user moves while touching | event, zoomableViewEventObject | {boolean} if true is returned, pinch and shift operations will not be processed | ### zoomableViewEventObject @@ -282,8 +276,6 @@ The zoomableViewEventObject object is attached to every event and represents the offsetY: number, // current offset top originalHeight: number, // original height of the zoom subject originalWidth: number, // original width of the zoom subject - originalPageX: number, // original absolute X of the zoom subject - originalPageY: number, // original absolite Y of the zoom subject } ``` diff --git a/example/App.tsx b/example/App.tsx index 8d249fd..abd7908 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,7 +1,20 @@ -import { ReactNativeZoomableView } from '@openspacelabs/react-native-zoomable-view'; +import { + FixedSize, + ReactNativeZoomableView, + ReactNativeZoomableViewRef, +} from '@openspacelabs/react-native-zoomable-view'; import { debounce } from 'lodash'; -import React, { useCallback, useRef, useState } from 'react'; -import { Animated, Button, Image, Text, View } from 'react-native'; +import React, { ReactNode, useCallback, useRef, useState } from 'react'; +import { + Alert, + Button, + Image, + Modal, + Text, + View, + ViewProps, +} from 'react-native'; +import { scheduleOnRN } from 'react-native-worklets'; import { applyContainResizeMode } from '../src/helper/coordinateConversion'; import { styles } from './style'; @@ -13,10 +26,24 @@ const imageSize = { width: kittenSize, height: kittenSize }; const stringifyPoint = (point?: { x: number; y: number }) => point ? `${Math.round(point.x)}, ${Math.round(point.y)}` : 'Off map'; +const PageSheetModal = ({ + children, + style, +}: { + children: ReactNode; + style?: ViewProps['style']; +}) => { + return ( + + {children} + + ); +}; + export default function App() { - const zoomAnimatedValue = useRef(new Animated.Value(1)).current; - const scale = Animated.divide(1, zoomAnimatedValue); + const ref = useRef(null); const [showMarkers, setShowMarkers] = useState(true); + const [modal, setModal] = useState(false); const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0, @@ -36,8 +63,10 @@ export default function App() { const staticPinPosition = { x: size.width / 2, y: size.height / 2 }; const { size: contentSize } = applyContainResizeMode(imageSize, size); + const Wrapper = modal ? PageSheetModal : View; + return ( - + ReactNativeZoomableView { + Alert.alert('Long press detected'); + }} // Where to put the pin in the content view staticPinPosition={staticPinPosition} // Callback that returns the position of the pin // on the actual source image onStaticPinPositionChange={debouncedUpdatePin} - onStaticPinPositionMove={debouncedUpdateMovePin} + onStaticPinPositionMove={(position) => { + 'worklet'; + scheduleOnRN(debouncedUpdateMovePin, position); + }} maxZoom={30} // Give these to the zoomable view so it can apply the boundaries around the actual content. // Need to make sure the content is actually centered and the width and height are // measured when it's rendered naturally. Not the intrinsic sizes. contentWidth={contentSize?.width ?? 0} contentHeight={contentSize?.height ?? 0} - zoomAnimatedValue={zoomAnimatedValue} > {showMarkers && - (['20%', '40%', '60%', '80%'] as const).map((left) => - (['20%', '40%', '60%', '80%'] as const).map((top) => ( - + [20, 40, 60, 80].map((left) => + [20, 40, 60, 80].map((top) => ( + + + )) )} @@ -88,6 +118,15 @@ export default function App() { setShowMarkers((value) => !value); }} /> - + +