Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6db57ff
feat: add NonScalingOverlay translate-only marker overlay
May 10, 2026
b65a986
chore: untrack example/ios prebuild artifact (app.json is source of t…
May 10, 2026
d075275
revert: drop example/ios/ gitignore — native edits must remain trackable
May 10, 2026
86039bd
fix: track Info.plist as source instead of app.json prebuild config
May 10, 2026
30125ff
chore: replace dead placekitten.com with picsum.photos
May 10, 2026
a760053
refactor: unify NonScalingOverlay transform — single 5-element list, …
May 10, 2026
830d98e
refactor: remove FixedSize, replaced by NonScalingOverlay translate-o…
May 10, 2026
1f29392
fix(NonScalingOverlay): anchor at top:0/left:0 to defeat Yoga centering
May 11, 2026
544d06d
chore: untrack example/ios/ — Expo Go workflow, prebuild output is ep…
May 11, 2026
7c28e54
example: drop applyContainResizeMode, size contents by aspectRatio
May 11, 2026
d4b9734
example(metro): dedupe peers via resolveRequest, drop semver pin
May 11, 2026
b8a6df6
refactor: drop inverseZoom/inverseZoomStyle from context — dead surfa…
May 11, 2026
e76d21f
chore(test): add jest + RTL + reanimated mock infra
May 14, 2026
d41f10d
refactor(NonScalingOverlay): extract computeOverlayTransform + unit t…
May 14, 2026
0ae8f80
test(NonScalingOverlay): component tests for EC-NSO-1, 2, 7, 8, 9
May 14, 2026
a22d18e
test(ReactNativeZoomableView): renderOverlay integration tests for EC…
May 14, 2026
011a973
refactor(helper): extract calcShiftDelta from _calcOffsetShiftSinceLa…
May 14, 2026
404e3ba
refactor(helper): extract applyPinchSensitivity for pinch resistance …
May 14, 2026
cfab3c8
refactor(helper): extract clampZoom for pinch min/max clamp
May 14, 2026
3be2f77
refactor(helper): extract shouldSkipShift predicate from _handleShifting
May 14, 2026
cced0e0
chore(test): tag canvas gesture with withTestId for jest-utils
May 14, 2026
a35ec51
test(helper): unit tests for pure-helper SPEC items
May 14, 2026
bf04142
test(tree+StaticPin): tree topology + StaticPin styling (SPEC-086, 08…
May 14, 2026
6311430
test(RNZV): props + imperativeHandle + callbacks (SPEC-008/011-018/03…
May 14, 2026
4af5533
test(RNZV): static-pin + feedback + useLatestWorklet (SPEC-042-051, 0…
May 14, 2026
2fe4687
test(gestures): tap classification — single/double/long-press (SPEC-0…
May 14, 2026
9c611f6
test(gestures): pan callbacks + onPanResponderMoveWorklet intercept (…
May 14, 2026
563d90a
test(gestures): pinch + shift + multi-finger (SPEC-014, 058, 064, 088…
May 14, 2026
3b84a9b
revert: move test suite to dedicated PR
May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ android.iml
#
example/ios/Pods

# Expo Go is the deploy path; example/ios/ is regenerable prebuild output.
example/ios/

# node.js
#
node_modules/
Expand Down
6 changes: 3 additions & 3 deletions SPECS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ Exported from `src/index.tsx`:
- `ReactNativeZoomableViewProps` — prop type
- `ReactNativeZoomableViewRef` — imperative handle
- `ZoomableViewEvent` — `{ zoomLevel, offsetX, offsetY, originalWidth, originalHeight }`
- `useZoomableViewContext()` — hook returning `{ zoom, inverseZoom, inverseZoomStyle, offsetX, offsetY }` for descendants
- `FixedSize` — wrapper that keeps absolutely-positioned children at constant visual size regardless of zoom
- `useZoomableViewContext()` — hook returning `{ zoom, offsetX, offsetY }` for descendants
- `NonScalingOverlay` — translate-only overlay component; markers position via `left: 'X%' / top: 'Y%'` and render at 1:1 screen size at every zoom level. Typically used via the `renderOverlay` prop on `ReactNativeZoomableView`; can also be mounted manually with explicit `contentWidth`/`contentHeight`/`wrapperWidth`/`wrapperHeight` numeric props plus SharedValue `zoom`/`offsetX`/`offsetY` (and optional `rotation`).
- `applyContainResizeMode`, `getImageOriginOnTransformSubject`, `viewportPositionToImagePosition` — coordinate helpers

---
Expand Down Expand Up @@ -317,7 +317,7 @@ This major replaces the class-component PanResponder/Animated implementation wit
### New public exports

- `useZoomableViewContext()`
- `FixedSize`
- `NonScalingOverlay` (with `renderOverlay` prop on `ReactNativeZoomableView` as the primary entry point)
- `ReactNativeZoomableViewRef` typed imperative handle
- `applyContainResizeMode`, `getImageOriginOnTransformSubject`, `viewportPositionToImagePosition`

Expand Down
133 changes: 115 additions & 18 deletions example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
FixedSize,
ReactNativeZoomableView,
ReactNativeZoomableViewRef,
} from '@openspacelabs/react-native-zoomable-view';
Expand Down Expand Up @@ -27,12 +26,16 @@ import Animated, {
} from 'react-native-reanimated';
import { ReText } from 'react-native-redash';

import { applyContainResizeMode } from '../src/helper/coordinateConversion';
import { styles } from './style';

const kittenSize = 800;
const uri = `https://placekitten.com/${kittenSize}/${kittenSize}`;
const imageSize = { width: kittenSize, height: kittenSize };
// `placekitten.com` has been offline for an extended period. Lorem Picsum
// is the reliable drop-in (CDN-backed via Cloudflare, accepts the same
// `/<W>/<H>` URL shape). Loses the cat theme — placecats.com is the
// cat-themed alternative — but Picsum's reliability matters more for an
// example app that needs to actually render the image on every fresh
// install.
const uri = `https://picsum.photos/${kittenSize}/${kittenSize}`;

const stringifyPoint = (point?: { x: number; y: number }) =>
point ? `${Math.round(point.x)}, ${Math.round(point.y)}` : 'Off map';
Expand Down Expand Up @@ -209,7 +212,22 @@ export default function App() {
});

const staticPinPosition = { x: size.width / 2, y: size.height / 2 };
const { size: contentSize } = applyContainResizeMode(imageSize, size);

// Capture the source dims via `onLoad` so the contents View can be
// sized to match the image's aspect ratio. With matching aspect, RN's
// resizeMode 'contain' produces zero letterbox — the rendered-pixel
// frame equals the element frame — and the contents View's onLayout
// gives `NonScalingOverlay` the exact `contentWidth`/`contentHeight`
// it needs (no separate `applyContainResizeMode` step).
const [imageSourceSize, setImageSourceSize] = useState<{
width: number;
height: number;
}>({ width: kittenSize, height: kittenSize });
const sourceAspect = imageSourceSize.width / imageSourceSize.height;
const [contentSize, setContentSize] = useState<{
width: number;
height: number;
}>({ width: 0, height: 0 });

const Wrapper = modal ? PageSheetModal : View;

Expand Down Expand Up @@ -250,23 +268,102 @@ export default function App() {
// 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}
contentWidth={contentSize.width}
contentHeight={contentSize.height}
renderOverlay={
showMarkers
? () => {
// Wrapper ≈ box minus its 5pt border each side. The
// lib doesn't expose its `wrapperSize` directly, but
// `box.width/height - 10` reproduces it on this
// example. Used only for the debug HUD.
const wrapperApproxW = Math.max(0, size.width - 10);
const wrapperApproxH = Math.max(0, size.height - 10);
return (
<>
{/* DEBUG: visualize the overlay's bounding box.
Sized 100% × 100% of the overlay so it tracks
contentSize × zoom and reveals where the
translate-only overlay is actually painting on
screen. Example-only — remove for production. */}
<View style={styles.overlayDebugBox} />
{/* DEBUG HUD pinned to overlay's top-left so it
tracks the overlay's transform and provides
the live numbers used by the translate math
(translateX/Y at z=1, ox=oy=0). */}
<Text style={styles.overlayDebugHud}>
NSOL wW≈{Math.round(wrapperApproxW)} wH≈
{Math.round(wrapperApproxH)} cW=
{Math.round(contentSize.width)} cH=
{Math.round(contentSize.height)} tXjs=
{Math.round(
wrapperApproxW / 2 - contentSize.width / 2
)}{' '}
tYjs=
{Math.round(
wrapperApproxH / 2 - contentSize.height / 2
)}
</Text>
{[20, 40, 60, 80].map((left) =>
[20, 40, 60, 80].map((top) => (
<View
key={`${left}x${top}`}
Comment on lines +284 to +310
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The example app ships debug-only visualizations the author themselves flagged for removal: overlayDebugBox (red 18%-opacity rect with magenta border), overlayDebugHud (yellow text overlay with raw transform math), and a DBG contentSize: Text line below the zoomable view — all with comments saying "Example-only — remove for production". Separately, the inline comment on the contents View (around lines 320-336) describes maxWidth/maxHeight: '100%' and alignSelf: 'center' styles that don't exist anywhere — styles.contents only sets alignSelf: 'stretch', and the style.ts comment is the accurate one. Either delete the debug surfaces and fix the comment before merge, or gate them behind a __DEV__ toggle.

Extended reasoning...

Two cleanup items in example/App.tsx, both example-only

1. Debug surfaces marked "remove for production" still ship unconditionally

Three debug-only render outputs are committed:

  • example/App.tsx:288-289<View style={styles.overlayDebugBox} />, immediately preceded by the comment /* DEBUG: visualize the overlay's bounding box. ... Example-only — remove for production. */. The style itself (example/style.ts:60-72) is a magenta-bordered, 18%-opacity red rect filling the overlay; its own comment reads Example-only debug visualization for NonScalingOverlay's bounding box. ... Not exported by the library.
  • example/App.tsx:290-306 — yellow overlayDebugHud Text pinned to the overlay's top-left, displaying raw transform math: NSOL wW≈… wH≈… cW=… cH=… tXjs=… tYjs=…. Style at example/style.ts:73-85.
  • example/App.tsx:362-366<Text>DBG contentSize: …×… box: …×…</Text> rendered below the zoomable view (outside the showMarkers gate — it always renders).

The overlay debug surfaces are gated only by showMarkers, which is a user-facing toggle defaulting to true. The DBG Text below has no gate at all.

2. App.tsx contents-View comment contradicts the actual styles

example/App.tsx:320-336 (the comment on the contents <View>) describes the layout strategy as:

Combined with maxWidth/maxHeight: '100%' and alignSelf: 'center', the element fits within the wrapper on whichever axis is binding…

But example/style.ts:28-41 (styles.contents) only sets alignSelf: 'stretch'. There is no maxWidth, no maxHeight, and alignSelf is stretch, not center. The only inline override applied at use time is { aspectRatio: sourceAspect }. The neighboring style.ts comment (alignSelf: 'stretch' anchors cross-axis, aspectRatio derives main-axis, parent's justifyContent: 'center' centers it vertically) is the accurate description — so the two comments actively contradict each other.

Step-by-step proof

Take example/App.tsx at HEAD on this branch, render in a simulator with default props (showMarkers = true, the initial state on line 196):

  1. renderOverlay fires the truthy branch (line 273-330).
  2. The overlay tree contains <View style={styles.overlayDebugBox} /> — a magenta-bordered, red 18%-opacity rectangle fills the entire overlay region.
  3. <Text style={styles.overlayDebugHud}>NSOL wW≈… cH=…</Text> paints a yellow band of debug math across the top of the image.
  4. Below the zoom box, <Text>DBG contentSize: …×… box: …×…</Text> renders unconditionally — flipping showMarkers off does not hide it.

For the comment mismatch: grep -n 'maxWidth\|maxHeight\|alignSelf' example/style.ts returns exactly one match — alignSelf: 'stretch' inside styles.contents. The strings maxWidth, maxHeight, and alignSelf: 'center' cited by the App.tsx comment do not appear anywhere in the file.

Why this is "nit" severity

This is the example app, not library source — published consumers are unaffected. The library exports NonScalingOverlay and the renderOverlay prop; the debug rectangles, HUD, and DBG line live in the consumer-side demo and never reach the npm artifact. But the example app is the library's primary public demo ("Example app: pinch / pan with default StaticPin" appears in this PR's own test plan), and the author's own "Example-only — remove for production" annotation is the strongest possible evidence the items are unintentional carryover from development.

Fix

Either:

  • Delete the three debug surfaces (App.tsx:288-306, App.tsx:362-366) and the two style entries (style.ts:56-85) before merge, OR
  • Wrap each in an if (__DEV__ && debug) (or a dedicated state toggle distinct from showMarkers) so the default demo experience is clean.

Either way, update the App.tsx contents-View comment (lines 320-336) to match what's actually applied — drop the maxWidth/maxHeight / alignSelf: center description and reference the same alignSelf: stretch + aspectRatio explanation that the style.ts comment already gives.

style={[
styles.marker,
{
left: `${left}%`,
top: `${top}%`,
},
]}
/>
))
)}
</>
);
}
: undefined
}
>
<View style={styles.contents}>
<Image style={styles.img} source={{ uri }} />

{showMarkers &&
[20, 40, 60, 80].map((left) =>
[20, 40, 60, 80].map((top) => (
<FixedSize left={left} top={top} key={`${left}x${top}`}>
<View style={styles.marker} />
</FixedSize>
))
)}
<View
// `aspectRatio` constrains the contents View to the
// source's aspect, so resizeMode:contain produces zero
// letterbox — the element frame == the rendered-pixel
// frame. Combined with `maxWidth/maxHeight: '100%'` and
// `alignSelf: 'center'`, the element fits within the
// wrapper on whichever axis is binding (width on a tall
// wrapper, height on a wide wrapper) without overflow.
// `onLayout` then gives `NonScalingOverlay` the exact
// contentSize directly — no extra contain math required.
style={[styles.contents, { aspectRatio: sourceAspect }]}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
setContentSize((prev) =>
prev.width === width && prev.height === height
? prev
: { width, height }
);
}}
>
<Image
style={styles.img}
source={{ uri }}
onLoad={(e) => {
const src = e.nativeEvent.source;
setImageSourceSize((prev) =>
prev.width === src.width && prev.height === src.height
? prev
: { width: src.width, height: src.height }
);
}}
/>
</View>
</ReactNativeZoomableView>
</View>
<Text>
DBG contentSize: {Math.round(contentSize.width)}×
{Math.round(contentSize.height)} box: {Math.round(size.width)}×
{Math.round(size.height)}
</Text>
<Text>onStaticPinPositionChange: {stringifyPoint(pin)}</Text>
<ReText text={movePinText} style={{ color: 'black' }} />
<Button
Expand Down
3 changes: 2 additions & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-redash": "18.1.5",
"react-native-web": "~0.21.0"
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
Expand Down
54 changes: 51 additions & 3 deletions example/style.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { StyleSheet } from 'react-native';
import { Dimensions, StyleSheet } from 'react-native';

// Box width must fit the device viewport. A fixed pt width (e.g. 480)
// exceeds smaller screens (iPhone 12 Pro = 390pt), pushing part of the
// rendered image off-screen. The overlay places dots as percentages of
// contentSize (the rendered-image frame); when contentSize is wider
// than the visible screen, dots at 20%/80% of content land at ~14%/86%
// of what the user sees. `width: '100%'` doesn't work here because the
// nested container chain uses `alignItems: 'center'`, which leaves the
// parent's cross-axis intrinsic-sized — '100%' resolves to 0.
// Subtracting 40 leaves a 20pt margin from each screen edge.
const BOX_WIDTH = Dimensions.get('window').width - 40;

export const styles = StyleSheet.create({
box: {
borderWidth: 5,
flexShrink: 1,
height: 600,
width: 480,
width: BOX_WIDTH,
},
container: {
alignItems: 'center',
Expand All @@ -15,8 +26,18 @@ export const styles = StyleSheet.create({
padding: 20,
},
contents: {
// `alignSelf: 'stretch'` anchors the cross-axis (width) to the
// parent's full width; the `aspectRatio` set inline at render time
// from the loaded image's source dims then derives the main-axis
// (height). Together they size the contents View to match the
// image's aspect exactly — resizeMode:contain produces zero
// letterbox, so the element frame == the rendered-pixel frame.
// The parent's `justifyContent: 'center'` centers it vertically.
// (Suitable when the child fits within the parent's main-axis at
// parent-width × aspect — true for the picsum square in 340×590.
// For arbitrary aspects, additionally clamp via
// `maxHeight: '100%'` and pre-compute the binding axis externally.)
alignSelf: 'stretch',
flex: 1,
},
img: {
height: '100%',
Expand All @@ -35,4 +56,31 @@ export const styles = StyleSheet.create({
top: '50%',
width: 20,
},
// Example-only debug visualization for NonScalingOverlay's bounding
// box. Filling 100% × 100% of the overlay shows where the
// translate-only layer is actually painting — useful for verifying
// alignment at every zoom level. Not exported by the library.
overlayDebugBox: {
backgroundColor: 'rgba(255,0,0,0.18)',
borderColor: 'magenta',
borderWidth: 2,
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
// Floating HUD anchored to the overlay's top-left corner — shows the
// wrapper / content / translate numbers used by NonScalingOverlay's
// transform math, so visual misalignment can be cross-checked against
// raw values without leaving the screen.
overlayDebugHud: {
backgroundColor: 'yellow',
color: 'black',
fontSize: 9,
left: 0,
position: 'absolute',
top: 0,
width: 280,
},
});
Loading
Loading