Skip to content

fix(renderer): macOS double-tap via native smartMagnify event (supersedes broken minPointers approach)#34

Open
LeslieOA wants to merge 1 commit into
developfrom
fix/macos-smart-magnify
Open

fix(renderer): macOS double-tap via native smartMagnify event (supersedes broken minPointers approach)#34
LeslieOA wants to merge 1 commit into
developfrom
fix/macos-smart-magnify

Conversation

@LeslieOA

Copy link
Copy Markdown
Member

What changed

The macOS two-finger double-tap fix landed in commit 9cd620b (via #23) used Gesture.Tap().minPointers(2) on macOS. That doesn't work. Workspace ran diagnostics on this approach (their PR #183) and found:

Every trackpad tap arrives in RNGH-macos as a single-pointer event. The count=2 we see in the touch lifecycle is a cumulative artefact, not the initial state — so a minPointers(2) predicate fails on the first touch and the gesture never even starts.

This PR ports their correct fix, library-side.

The actual mechanism

macOS routes trackpad two-finger taps through NSResponder.smartMagnifyWithEvent: rather than touch events. Bridge it natively:

NSEvent (smartMagnify, trackpad)
  → CanvasScrollInterceptor.smartMagnify(with:)        [consumer-side Swift]
    → post "SmartMagnifyEvent" notification (x, y)     [consumer-side Swift]
      → ScrollWheelBridge observes                     [consumer-side Swift]
        → emit 'onSmartMagnify' to JS                  [consumer-side Swift]
          → CanvasView listener converts to world      [LIBRARY — this PR]
            → handleDoubleTap(wx, wy)                  [LIBRARY — unchanged]

Files in this PR

File Change
src/renderer/NativeScrollWheelView.tsx Add SmartMagnifyEvent type with explanatory docstring (the docstring is the spec for consumers wiring the Swift side)
src/renderer/CanvasView.tsx Three small changes: (1) import SmartMagnifyEvent, (2) useEffect listener for onSmartMagnify next to the existing onScrollWheel listener, (3) drop minPointers(2) and add Platform.OS === 'macos' early-return in the tap gesture's onEnd

51 additions, 7 deletions across 2 files. No other source touched.

Consumer-side responsibility (NOT in this PR)

Consumers need to provide a Swift module that handles smartMagnifyWithEvent: and emits onSmartMagnify through their ScrollWheelBridge. Workspace's apps/desktop/macos/.../NativeModules/CanvasScrollInterceptor.swift is the reference implementation; the JSDoc on SmartMagnifyEvent points there.

For our own example/macos-app (on PR #30): a follow-up will update its README post-merge to point at this consumer-side requirement. Not reaching into #30's branch from here.

Verification

  • npm test: 38/38
  • npm run typecheck: clean
  • npm run lint: exit 0
  • grep minPointers src/renderer/ returns only the comment explaining why we don't use it

Manual verification (requires a Mac + Workspace-style smart magnify bridge wired up, or post-#30-merge + Swift module):

  • macOS: two-finger trackpad double-tap on a node → zooms in
  • macOS: single-finger double-tap / mouse double-click → no-op
  • iOS: single-finger double-tap on a node → still zooms (no regression)
  • macOS: pan, pinch, scroll-wheel zoom all still work
  • macOS: sidebar inset on smartMagnify is inherited from the existing handleDoubleTap path

Closes / supersedes

Refs: #33

…33)

Supersedes the minPointers(2) approach from #23 / commit 9cd620b. That
fix doesn't work — Workspace's diagnostic pass (their PR #183) confirmed
RNGH-macos receives every trackpad tap as a single-pointer event, so
minPointers(2) fails on first touch and the gesture never starts.

The actual mechanism: macOS routes trackpad two-finger taps through
NSResponder.smartMagnifyWithEvent: rather than touch events. Consumers
wire a Swift module that listens for that and emits an `onSmartMagnify`
event via the existing ScrollWheelBridge native module; the library
subscribes to that event and converts to world coordinates the same way
the iOS single-finger RNGH tap path does.

Library-side changes (this commit)

src/renderer/NativeScrollWheelView.tsx
  - Add SmartMagnifyEvent type. Top-left-origin {x, y} matches the
    shape of RNGH's TapGesture event so the consumer can reuse the
    world-coordinate conversion path.

src/renderer/CanvasView.tsx
  - Remove the minPointers(2) macOS branch from the tap gesture — it
    does nothing useful and was misleading.
  - Add a useEffect listener for `onSmartMagnify` events that converts
    to world coords and calls handleDoubleTap. Lives next to the
    existing onScrollWheel listener for bridging consistency.
  - Add Platform.OS === 'macos' early-return in the tap gesture's
    onEnd. The RNGH tap still races with pan (so single-finger taps
    don't accidentally trigger pan logic), but the click itself is a
    no-op on macOS — Smart Zoom is the only path that fires
    handleDoubleTap.

Consumer-side (NOT in this commit)

Consumers need to provide a Swift module that handles
smartMagnifyWithEvent: and emits onSmartMagnify through
ScrollWheelBridge. Workspace's apps/desktop/macos/.../NativeModules/
CanvasScrollInterceptor.swift is the reference implementation —
linked from the JSDoc on SmartMagnifyEvent.

The macos-app harness (added in #21 / PR #30) will need its README
updated post-merge to point at this consumer requirement. Filed as a
follow-up rather than reaching into PR #30's branch.

Verification

- npm test: 38/38
- npm run typecheck: clean
- npm run lint: exit 0
- Grep confirms no minPointers usage remains in the renderer source.

Manual verification (you, on a Mac with a real Workspace-style smart
magnify bridge wired up, or after #30 merges and someone wires the
Swift module):

  - macOS: two-finger trackpad double-tap on a node zooms to that node
  - macOS: single-finger double-tap / mouse double-click is a no-op
  - iOS: single-finger double-tap on a node still zooms (unchanged)

Refs: #33
Supersedes: #23

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LeslieOA added a commit that referenced this pull request May 25, 2026
…macos-harness

Bypasses react-native-macos-init entirely. The upstream tool has three
known incompatibilities (workspaces ENOWORKSPACES, peer-dep ERESOLVE
between react 19.1.0 and ^19.1.4, util.styleText brittleness on
Node < 20.12) that I previously documented as a manual setup procedure
in the README. That was an honest answer but a poor user experience —
the user has to bootstrap a temp project, install with --legacy-peer-deps,
run init, copy macos/ back, find-and-replace project name references.

The workspace-sh org already has a working solved-this-problem template
in `enriched-markdown-macos-harness` — a macOS harness for the
enriched-markdown library, structured identically to what we want.
Lifted its macos/ wholesale, customised only:

- example/macos-app/macos/workspace-macOS/Info.plist
    CFBundleName "Enriched Markdown Harness" → "JSON Canvas Playground"
- example/macos-app/macos/workspace.xcodeproj/project.pbxproj
    PRODUCT_BUNDLE_IDENTIFIER from
    "org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)" to
    "sh.workspace.jsoncanvas.macos-playground" (all 4 occurrences across
    Debug / Release × app / .dSYM build configs)
- example/macos-app/app.json
    name "macos-app" → "workspace" (must match AppDelegate.mm's
    self.moduleName = @"workspace" which we inherit from the template)
- example/macos-app/macos/.gitignore
    Added .xcode.env.local + xcuserdata + build / DerivedData entries
    that the template's .gitignore missed

Everything else inherited as-is, including:

- The Podfile's Xcode 26+ fmt-as-C++17 workaround in post_install
  (fmt 11.0.2's FMT_STRING uses consteval which newer Clang rejects;
  the harness folks already solved this — we get it for free)
- AppDelegate / main.m / Info.plist / .entitlements / Main.storyboard
  (the standard react-native-macos shell)
- workspace.xcodeproj/project.pbxproj (with our bundle id substituted)
- workspace.xcworkspace
- PrivacyInfo.xcprivacy

Root + library changes

- package.json: dropped the desktop:init script entirely (no longer
  applicable — the native scaffold ships in-repo)
- README.md: rewrote the "Playground app (macOS)" section to match
  the new flow (install → pods → macos, no init)
- example/macos-app/README.md: rewrote setup section explaining
  template provenance and current state

Verification

- Library checks pass on this branch: npm test 38/38, typecheck clean,
  lint exit 0
- example/macos-app/app.json's name "workspace" matches AppDelegate.mm's
  registered moduleName "workspace" (grep confirmed)
- example/macos-app/macos/ is 140K, 18 source files — no Pods/, no
  build/, no machine-local files

What you still need to do once

On your Mac, the smoke flow becomes:

    npm run desktop:install   # workspace deps with --legacy-peer-deps
    npm run desktop:pods      # pod install in example/macos-app/macos/
    npm run desktop:macos     # build + launch

If you also want Safari-style two-finger double-tap zoom (the work in
PR #34), you'll need to add a CanvasScrollInterceptor.swift to the
macOS target — reference impl in Workspace's apps/desktop. Documented
in example/macos-app/README.md.

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LeslieOA added a commit that referenced this pull request May 25, 2026
…dule

Real fix for two-finger trackpad zoom on macOS. The library now ships its
own native event bridge — consumers don't write Swift, they just
`npm install` and pod install picks it up via autolinking.

Why a library-shipped native module

The previous JS-only fix attempt (`minPointers(2)` in RNGH's Tap) silently
never fires on RNGH-macos — trackpad taps arrive as single-pointer events,
the predicate fails on first touch, the gesture never starts. Workspace
diagnostics in workspace-sh/workspace#183 confirmed this.

The actual mechanism: macOS routes trackpad two-finger taps through
`NSResponder.smartMagnifyWithEvent:` (Safari's "Smart Zoom"). RNGH
doesn't bridge it. Workspace's apps/desktop solves this by writing a
Swift `CanvasScrollInterceptor` + `ScrollWheelBridge` in their own app.
That's fine for an app, wrong for a library — every consumer would have
to write the same Swift boilerplate.

This commit moves that Swift code into the library, behind autolinking.

Files

ios/WorkspaceJsonCanvasGesture.swift
  RCTEventEmitter subclass. Installs an `NSEvent.addLocalMonitorForEvents`
  hook for `.smartMagnify` events, forwards them to JS as `onSmartMagnify`
  with top-left-origin window coordinates (matching the iOS RNGH tap event
  shape). Whole class wrapped in `#if os(macOS)` so the file is safe on
  iOS/Android builds.

  KNOWN LIMITATION (v1): the monitor is window-global, so coordinates are
  window-relative not canvas-view-relative. Fine for a full-window canvas
  (the playground harness). An app with chrome (sidebar, toolbar inset)
  needs to subtract its own offsets. Documented on the
  `SmartMagnifyEvent` type's JSDoc. A future view-component variant will
  give per-instance view-local coords.

ios/WorkspaceJsonCanvasGestureBridge.m
  Objective-C `RCT_EXTERN_MODULE` shim that registers the Swift class
  with React Native's bridge. Gated on `TARGET_OS_OSX` so the linker
  doesn't choke on iOS.

react-native-jsoncanvas.podspec
  Pod definition at repo root. `s.platforms = { :osx => '11.0' }` —
  macOS-only for now. `s.source_files = 'ios/**/*'` picks up everything
  in the ios/ directory; the per-file platform guards handle the rest.

react-native.config.js
  Autolinker config. `dependency.platforms = { ios: null, android: null }`
  tells the autolinker not to integrate on those platforms (we have no
  iOS/Android native code today). The macOS Pod is picked up by react-
  native-macos's autolinking via the podspec at the repo root.

JS changes

src/renderer/NativeScrollWheelView.tsx
  - Added `SmartMagnifyEvent` type with full JSDoc on the v1 window-coord
    limitation
  - Added `jsonCanvasGestureEvents` — NativeEventEmitter wrapping our
    own `WorkspaceJsonCanvasGesture` module, separate from the
    consumer-provided `ScrollWheelBridge` (which stays for the existing
    `onScrollWheel` scroll-pan integration; that's an app-level concern
    so it's a peer dep, not something we ship)

src/renderer/CanvasView.tsx
  - Removed the broken `minPointers(2)` macOS branch on the tap gesture.
    It was a wrong fix that never fired on RNGH-macos.
  - Added a new `useEffect` listener on `jsonCanvasGestureEvents` for
    `onSmartMagnify` that calls `handleDoubleTap(wx, wy)` with
    world-coord-converted tap location. Sits next to the existing
    `onScrollWheel` listener for bridging consistency.
  - Added a `Platform.OS === 'macos'` early-return in the RNGH tap's
    `onEnd` so single-finger double-clicks don't trigger handleDoubleTap
    twice (RNGH tap still races with pan to keep pan gestures clean,
    but the click itself is a no-op on macOS).

Consumer flow after this lands

  cd example/macos-app
  npm install                            # autolinks the new Pod
  npm run desktop:macos:pods             # pod install picks it up
  npm run desktop:macos:dev              # build + run

  Two-finger trackpad double-tap on a node should now zoom to that
  node. Single-finger tap / mouse double-click: no-op (intentional).
  iOS / Android: unchanged.

Supersedes #33 / #34 (which were JS-only attempts at the same fix
that this work obsoletes).

Refs: #21, #23, #33

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LeslieOA added a commit that referenced this pull request May 26, 2026
…-testing (#30)

* feat: scaffold bare-RN + react-native-macos harness (closes #21)

Sibling to example/expo-app — same App.tsx shape and same hesprs-demo
fixture, but in a vanilla macOS window rather than Expo. Completes the
harness story (Expo for iOS / Android / web; this for macOS).

What this PR ships

JavaScript / TypeScript / Metro / Babel scaffolding only. Native
macos/ scaffold (Xcode project, Podfile, AppDelegate, NativeModules)
is NOT in this PR — it's generated by react-native-macos's own CLI on
the maintainer's Mac via `npm run desktop:init`.

Files

  example/macos-app/
    package.json       Pinned to Workspace apps/desktop's stack:
                       react@19.1.4 (exact), react-native@^0.81.5,
                       react-native-macos@^0.81.4, gesture-handler /
                       reanimated / worklets / skia matching the
                       Expo playground.
    app.json           macos-app / "JSON Canvas Playground (macOS)".
    index.js           AppRegistry.registerComponent — bare RN entry.
    App.tsx            Mirrors example/expo-app/App.tsx — Fit /
                       Recenter buttons, getLastAction status pill.
    fixtures.ts        Same hesprs-demo fixture as the Expo playground.
    metro.config.js    extraNodeModules + blockList pinning the local
                       react@19.1.4 so the Expo playground's
                       root-hoisted react@19.2.0 can't leak in.
                       Mirrors Workspace apps/desktop/metro.config.js.
    babel.config.js    @react-native/babel-preset + reanimated/plugin
                       + @babel/plugin-transform-export-namespace-from
                       (the syntax-tree ecosystem dependencies in the
                       library use export-namespace-from syntax that
                       RN's preset doesn't transform by default).
    tsconfig.json      Extends @react-native/typescript-config.
    .gitignore         Standard bare-RN ignores plus macos/Pods,
                       macos/build, DerivedData.
    README.md          Setup instructions for the one-time native init
                       and the run / clean / pods / start scripts.

Root scripts (mirror Workspace's mobile:* / desktop:* convention)

  desktop:install         npm install for the macOS app's workspace
  desktop:init            Run react-native-macos init + pod install
  desktop:pods            Re-run pod install (after native dep change)
  desktop:clean           rm macos/build + macos/Pods
  desktop:start           Metro on port 8083 (avoids Expo's 8082)
  desktop:clear           watchman watch-del-all + metro --reset-cache
  desktop:macos           react-native run-macos (build + launch)
  desktop:dev             concurrently: clear + macos

Deliberately NOT in this PR

- No NSSplitView, no custom title bar, no native bridges. The
  example/macos-app/README.md "What this harness is NOT" section
  enumerates the don't-list explicitly so future-me doesn't drift
  scope toward recreating Workspace's apps/desktop.
- No package.json devDeps for react-native-macos-init or
  react-native-cli — desktop:init uses npx to avoid persisting a
  tool we only run once.
- No CI for this harness — RN-macOS builds require macos-15 runners,
  Xcode, and a full pod install; not worth the runner minutes until
  there's a real consumer.

Verification

- Library checks still pass: npm test 38/38, typecheck clean, lint
  exit 0. The macos-app sits in isolation under example/, the
  library's tsconfig / eslint / jest configs all scope to src/.
- Every generated file syntactically valid (JSON parsed, JS Function
  constructor smoke).

Manual verification needed by maintainer

- Run npm run desktop:install + desktop:init to generate macos/
- npm run desktop:macos — confirm window opens, hesprs-demo renders,
  Fit / Recenter work, two-finger trackpad double-tap zooms a node

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(macos-app): document react-native-macos-init compatibility issues

Attempting to verify the desktop:init script end-to-end (in the spirit
of "make it actually testable") surfaced three upstream blockers:

1. ENOWORKSPACES: react-native-macos-init's internal npm install
   fails with "This command does not support workspaces."
   --no-workspaces isn't propagated into the tool's subprocess.

2. ERESOLVE: the RN community template installs react@19.1.0;
   react-native-macos@0.81.7 peers react@^19.1.4. The init's own
   npm install has no --legacy-peer-deps escape valve.

3. Node version sensitivity: the init's template generator calls
   util.styleText (added in Node 20.12). If npx resolves an older
   Node, generation completes without errors but produces empty
   output directories.

None of these are fixable from a consumer (us). The Microsoft
react-native-macos team is aware. Until upstream lands fixes, the
init step is documented as a manual procedure (bootstrap outside the
workspace, run with --legacy-peer-deps on Node ≥ 20.12, copy the
resulting macos/ back).

Changes

- package.json: desktop:init script now prints an error pointing at
  the manual steps. desktop:install gains --legacy-peer-deps to
  match the bootstrap conditions.
- example/macos-app/README.md: rewrites the "First-time setup"
  section with the three blockers documented and the manual procedure
  step-by-step.

Library checks still pass on this branch (38/38, typecheck clean,
lint exit 0). No source changes.

The Expo playground (example/expo-app/) remains the path of least
friction for testing the renderer — fully working today on iOS /
Android (and web with the caveats in #17).

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(macos-app): ship working macos/ template from enriched-markdown-macos-harness

Bypasses react-native-macos-init entirely. The upstream tool has three
known incompatibilities (workspaces ENOWORKSPACES, peer-dep ERESOLVE
between react 19.1.0 and ^19.1.4, util.styleText brittleness on
Node < 20.12) that I previously documented as a manual setup procedure
in the README. That was an honest answer but a poor user experience —
the user has to bootstrap a temp project, install with --legacy-peer-deps,
run init, copy macos/ back, find-and-replace project name references.

The workspace-sh org already has a working solved-this-problem template
in `enriched-markdown-macos-harness` — a macOS harness for the
enriched-markdown library, structured identically to what we want.
Lifted its macos/ wholesale, customised only:

- example/macos-app/macos/workspace-macOS/Info.plist
    CFBundleName "Enriched Markdown Harness" → "JSON Canvas Playground"
- example/macos-app/macos/workspace.xcodeproj/project.pbxproj
    PRODUCT_BUNDLE_IDENTIFIER from
    "org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)" to
    "sh.workspace.jsoncanvas.macos-playground" (all 4 occurrences across
    Debug / Release × app / .dSYM build configs)
- example/macos-app/app.json
    name "macos-app" → "workspace" (must match AppDelegate.mm's
    self.moduleName = @"workspace" which we inherit from the template)
- example/macos-app/macos/.gitignore
    Added .xcode.env.local + xcuserdata + build / DerivedData entries
    that the template's .gitignore missed

Everything else inherited as-is, including:

- The Podfile's Xcode 26+ fmt-as-C++17 workaround in post_install
  (fmt 11.0.2's FMT_STRING uses consteval which newer Clang rejects;
  the harness folks already solved this — we get it for free)
- AppDelegate / main.m / Info.plist / .entitlements / Main.storyboard
  (the standard react-native-macos shell)
- workspace.xcodeproj/project.pbxproj (with our bundle id substituted)
- workspace.xcworkspace
- PrivacyInfo.xcprivacy

Root + library changes

- package.json: dropped the desktop:init script entirely (no longer
  applicable — the native scaffold ships in-repo)
- README.md: rewrote the "Playground app (macOS)" section to match
  the new flow (install → pods → macos, no init)
- example/macos-app/README.md: rewrote setup section explaining
  template provenance and current state

Verification

- Library checks pass on this branch: npm test 38/38, typecheck clean,
  lint exit 0
- example/macos-app/app.json's name "workspace" matches AppDelegate.mm's
  registered moduleName "workspace" (grep confirmed)
- example/macos-app/macos/ is 140K, 18 source files — no Pods/, no
  build/, no machine-local files

What you still need to do once

On your Mac, the smoke flow becomes:

    npm run desktop:install   # workspace deps with --legacy-peer-deps
    npm run desktop:pods      # pod install in example/macos-app/macos/
    npm run desktop:macos     # build + launch

If you also want Safari-style two-finger double-tap zoom (the work in
PR #34), you'll need to add a CanvasScrollInterceptor.swift to the
macOS target — reference impl in Workspace's apps/desktop. Documented
in example/macos-app/README.md.

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(macos-app): fix pod install + xcodebuild for npm-workspaces layout

Unblocks the macOS harness end-to-end. Three real fixes layered on top of
the template lift, plus a script naming reorganisation.

1. Podfile :path — use_react_native! needs a RELATIVE path

   The harness template's `:path => '../node_modules/react-native-macos'`
   assumed bare-RN layout where react-native-macos installs into the app's
   own node_modules. Under our npm-workspaces setup it hoists to the
   monorepo root.

   First attempt (absolute via ws_dir walk-up): use_react_native! does
   string-level './' prepending on the path arg, so an absolute path
   ended up as './/Users/leslieoa/...' and broke.

   Final fix: hardcode the relative walk-up '../../../node_modules/
   react-native-macos'. Matches what the autolinker already resolves for
   gesture-handler / reanimated / worklets / skia per the link_native_modules
   log lines.

2. Postinstall react-native symlink — xcodebuild Hermes script fix

   The Hermes Pod's "Replace Hermes for the right configuration, if needed"
   build script hardcodes
       $PODS_ROOT/../../node_modules/react-native/scripts/xcode/with-environment.sh
   relative to macos/Pods. With react-native hoisted to the monorepo root
   (npm dedupe finds a single version that satisfies both example apps'
   pins), that path doesn't resolve and xcodebuild fails before any source
   compiles.

   Workspace's apps/desktop avoids this because apps/mobile pins
   react-native 0.83 exact and apps/desktop wants ^0.81 — npm can't
   hoist either, so both get local installs. Our example apps' pins
   happen to coexist at root, so we don't get the same accidental
   local install.

   Fix: a postinstall script at example/macos-app/scripts/link-hoisted-rn.js
   that walks up node_modules ancestors, finds whichever copy of
   react-native the monorepo resolved, and symlinks it into the app's
   local node_modules. Idempotent — leaves a real local install alone,
   recreates a stale symlink on every npm install.

3. .gitignore — .spm.pods/

   pod install now generates a Swift Package Manager cache under
   macos/.spm.pods/. Ignored.

4. Script naming reorganisation: <form-factor>:<platform>:<mode>

   The old ios:* / android:* / desktop:* surface duplicated Metro and
   install scripts across iOS and Android (they actually do identical
   work for the shared expo-app). Restructured to:

       mobile:install        npm install for example/expo-app
       mobile:start          Metro (platform-agnostic — JS bundler)
       mobile:clear          watchman + Metro --reset-cache
       mobile:ios:*          iOS-specific run / prebuild / clean / dev
       mobile:android:*      Android-specific run / prebuild / clean / dev

       desktop:install       npm install for example/macos-app
       desktop:start         Metro on port 8083
       desktop:clear         watchman + Metro --reset-cache
       desktop:macos:*       macOS-specific run / pods / clean / dev

   Form-factor level collapses Metro + install (one shared per app).
   Platform level adds the build/run specifics. Pattern scales to
   future windows / linux desktop variants and tablet-specific mobile
   variants without renaming anything.

   README's "Example apps" section rewritten to match.

5. project.pbxproj — pod install regen

   pod install added the workspace integration markers to the project
   file. Standard pod-install side effect, not a hand-edit.

Verification (your machine)

After pulling this branch:

    npm run desktop:install        # triggers postinstall symlink
    npm run desktop:macos:pods     # pod install (re-run not strictly
                                   #              required if Pods exist,
                                   #              but safe to confirm)
    npm run desktop:macos:dev      # Metro on 8083 + xcodebuild + launch

Library checks still pass: tsc clean, 38/38 tests, lint exit 0.

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(examples): respect system color scheme in App.tsx wrappers

The macOS playground rendered dark-scheme node colours (the renderer
calls useColorScheme() internally) on top of a hardcoded white root
background — node text was the right colour for dark mode but the
canvas-empty area looked completely wrong. Same shape in the Expo
playground.

Both App.tsx wrappers now read useColorScheme() and pick #000 / #fff
for the GestureHandlerRootView background to match what the renderer
is doing internally. Hot reload picks it up immediately on theme
switches.

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(macos-app): symlink react-native-macos too (not just react-native)

The "Bundle React Native code and images" build phase script on the
workspace-macOS target calls `../node_modules/react-native-macos/
scripts/react-native-xcode.sh` — same shape as the hermes-engine
"Replace Hermes" script that already needed the workaround for
react-native.

Generalised link-hoisted-rn.js: PACKAGES array lists every hoisted
React Native package that has at least one build script with a
relative path hardcoded into pbxproj. Each entry gets the same
symlink treatment. Add to the list as new "package not found"
xcodebuild errors surface from other Pod scripts (most autolinked
native modules use absolute paths in pbxproj so they're not
affected, but a few — hermes, react-native-macos — bake in
relative paths to their own scripts).

The script comment also documents the diagnostic pattern so the
next person who hits this knows what to do.

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(macos): ship native smartMagnify bridge as autolinked library module

Real fix for two-finger trackpad zoom on macOS. The library now ships its
own native event bridge — consumers don't write Swift, they just
`npm install` and pod install picks it up via autolinking.

Why a library-shipped native module

The previous JS-only fix attempt (`minPointers(2)` in RNGH's Tap) silently
never fires on RNGH-macos — trackpad taps arrive as single-pointer events,
the predicate fails on first touch, the gesture never starts. Workspace
diagnostics in workspace-sh/workspace#183 confirmed this.

The actual mechanism: macOS routes trackpad two-finger taps through
`NSResponder.smartMagnifyWithEvent:` (Safari's "Smart Zoom"). RNGH
doesn't bridge it. Workspace's apps/desktop solves this by writing a
Swift `CanvasScrollInterceptor` + `ScrollWheelBridge` in their own app.
That's fine for an app, wrong for a library — every consumer would have
to write the same Swift boilerplate.

This commit moves that Swift code into the library, behind autolinking.

Files

ios/WorkspaceJsonCanvasGesture.swift
  RCTEventEmitter subclass. Installs an `NSEvent.addLocalMonitorForEvents`
  hook for `.smartMagnify` events, forwards them to JS as `onSmartMagnify`
  with top-left-origin window coordinates (matching the iOS RNGH tap event
  shape). Whole class wrapped in `#if os(macOS)` so the file is safe on
  iOS/Android builds.

  KNOWN LIMITATION (v1): the monitor is window-global, so coordinates are
  window-relative not canvas-view-relative. Fine for a full-window canvas
  (the playground harness). An app with chrome (sidebar, toolbar inset)
  needs to subtract its own offsets. Documented on the
  `SmartMagnifyEvent` type's JSDoc. A future view-component variant will
  give per-instance view-local coords.

ios/WorkspaceJsonCanvasGestureBridge.m
  Objective-C `RCT_EXTERN_MODULE` shim that registers the Swift class
  with React Native's bridge. Gated on `TARGET_OS_OSX` so the linker
  doesn't choke on iOS.

react-native-jsoncanvas.podspec
  Pod definition at repo root. `s.platforms = { :osx => '11.0' }` —
  macOS-only for now. `s.source_files = 'ios/**/*'` picks up everything
  in the ios/ directory; the per-file platform guards handle the rest.

react-native.config.js
  Autolinker config. `dependency.platforms = { ios: null, android: null }`
  tells the autolinker not to integrate on those platforms (we have no
  iOS/Android native code today). The macOS Pod is picked up by react-
  native-macos's autolinking via the podspec at the repo root.

JS changes

src/renderer/NativeScrollWheelView.tsx
  - Added `SmartMagnifyEvent` type with full JSDoc on the v1 window-coord
    limitation
  - Added `jsonCanvasGestureEvents` — NativeEventEmitter wrapping our
    own `WorkspaceJsonCanvasGesture` module, separate from the
    consumer-provided `ScrollWheelBridge` (which stays for the existing
    `onScrollWheel` scroll-pan integration; that's an app-level concern
    so it's a peer dep, not something we ship)

src/renderer/CanvasView.tsx
  - Removed the broken `minPointers(2)` macOS branch on the tap gesture.
    It was a wrong fix that never fired on RNGH-macos.
  - Added a new `useEffect` listener on `jsonCanvasGestureEvents` for
    `onSmartMagnify` that calls `handleDoubleTap(wx, wy)` with
    world-coord-converted tap location. Sits next to the existing
    `onScrollWheel` listener for bridging consistency.
  - Added a `Platform.OS === 'macos'` early-return in the RNGH tap's
    `onEnd` so single-finger double-clicks don't trigger handleDoubleTap
    twice (RNGH tap still races with pan to keep pan gestures clean,
    but the click itself is a no-op on macOS).

Consumer flow after this lands

  cd example/macos-app
  npm install                            # autolinks the new Pod
  npm run desktop:macos:pods             # pod install picks it up
  npm run desktop:macos:dev              # build + run

  Two-finger trackpad double-tap on a node should now zoom to that
  node. Single-finger tap / mouse double-click: no-op (intentional).
  iOS / Android: unchanged.

Supersedes #33 / #34 (which were JS-only attempts at the same fix
that this work obsoletes).

Refs: #21, #23, #33

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(macos): unblock autolinker discovery of the library's own Pod

Two related fixes that together let `pod install` on macOS actually
pick up the WorkspaceJsonCanvasGesture native module shipped in
241d5a0.

1. react-native.config.js: drop the platforms exclusion

   Earlier version declared
       dependency: { platforms: { ios: null, android: null } }
   intending to signal "we have no iOS/Android native code". The macOS
   autolinker reads the same config and interprets the `ios: null`
   exclusion as "exclude from all platforms", silently dropping us
   from `pod install` on macOS too — exactly the opposite of what we
   wanted.

   Confirmed empirically: with that block in place, `npx
   @react-native-community/cli config` returned 4 dependencies
   (skia / gesture-handler / reanimated / worklets) and omitted us
   entirely. Removing it (or making the file `module.exports = {}`)
   adds `@workspace.sh/react-native-jsoncanvas` to the list.

   Comment in the new minimal config explains why we don't restore
   the previous shape — the podspec's own
       s.platforms = { :osx => '11.0' }
   handles iOS exclusion at the CocoaPods level, no autolinker config
   needed.

2. link-hoisted-rn.js: also symlink our own library

   The autolinker resolves dependencies via the consumer's local
   node_modules. Our `file:../..` self-reference symlinks to the
   monorepo root, but npm hoists that symlink to root/node_modules,
   not example/macos-app/node_modules. The autolinker doesn't walk
   up. Added @workspace.sh/react-native-jsoncanvas to the symlink
   PACKAGES list alongside react-native + react-native-macos so the
   postinstall hook creates a local symlink at:
       example/macos-app/node_modules/@workspace.sh/react-native-jsoncanvas
   pointing at the actual library.

Together these get the autolinker to see us, which gets pod install
to integrate the WorkspaceJsonCanvasGesture pod, which gets the
Swift code into the .app, which gets `onSmartMagnify` events firing.

Verification on the user's machine after pulling:

    npm install                          # postinstall: symlinks
    npm run desktop:macos:pods           # pod install picks up our Pod
    npm run desktop:macos:dev            # rebuild + launch

Then two-finger trackpad double-tap should fire handleDoubleTap.

Refs: #21

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(renderer): macOS double-tap via native smartMagnify event (supersedes broken minPointers approach)

1 participant