A React Native renderer for JSON Canvas — the open file format for infinite-canvas notes, originally from Obsidian.
Drop a .canvas file in and you get a panning, pinch-zooming canvas with text nodes (markdown), file nodes (images drawn, other files shown by name), grouped nodes, and connecting edges, drawn with Skia and Reanimated on iOS, macOS, and Android. The library exposes imperative controls for fit-to-viewport and recentre, plus built-in support for community conventions like Obsidian cssclasses and callouts.
It also renders on Linux through GTKX, React for GTK4 and libadwaita: the same document model, drawn with Cairo, as the ./gtk entry point. See Linux.
npm install @workspace.sh/react-native-jsoncanvasPeer dependencies for React Native (you provide these in your app; for Linux, see Linux):
reactreact-native@shopify/react-native-skiareact-native-gesture-handlerreact-native-reanimatedreact-native-worklets
import {useRef} from 'react';
import {CanvasView} from '@workspace.sh/react-native-jsoncanvas';
type Controls = {
fitToViewport: (leftInset?: number) => void;
recenter: (leftInset?: number) => void;
getLastAction: () => 'fit' | 'recenter' | 'manual';
};
export function MyCanvasScreen({canvasJson}: {canvasJson: string}) {
const controls = useRef<Controls | null>(null);
return (
<CanvasView
content={canvasJson}
onReady={c => {
controls.current = c;
}}
/>
);
}Call controls.current?.fitToViewport() or controls.current?.recenter() from any button to drive the camera.
The same <CanvasView /> works across iOS, Android, and macOS, and
Linux has its own <CanvasView /> with the same camera behaviour.
You don't need to wire up any platform-specific gesture handling —
the library ships every gesture it supports as a default.
| Gesture | iOS | Android | macOS | Linux |
|---|---|---|---|---|
| Pinch to zoom | ✓ | ✓ | ✓ (trackpad pinch) | ✓ (touchpad or touch pinch) |
| Pan / drag | ✓ | ✓ | ✓ (click-and-drag) | ✓ (click-and-drag) |
| Two-finger trackpad pan | — | — | ✓ | ✓ (scroll) |
| Ctrl + scroll to zoom | — | — | — | ✓ (about the pointer) |
| Double-tap to zoom-to-node | ✓ | ✓ | ✓ (trackpad two-finger double-tap, a.k.a. Smart Zoom) | — |
| Inertial fling | ✓ | ✓ | — (macOS pan is direct, no inertia) | — |
Install the library and its peer deps; that's it. All gestures run
through react-native-gesture-handler + react-native-reanimated. No
native module setup, no Xcode / Android Studio edits.
npm install @workspace.sh/react-native-jsoncanvas \
@shopify/react-native-skia \
react-native-gesture-handler \
react-native-reanimated \
react-native-workletsExpo: works in any SDK that supports the peer-dep range. Use
expo install if you prefer Expo's version-pinning. No native config
needed in app.json.
RNGH on macOS can't see trackpad multi-finger gestures
(RNGH-macos limitation),
so the library ships a small Swift RCTEventEmitter module
(WorkspaceJsonCanvasGesture) that hooks the raw AppKit event stream
(NSEvent.smartMagnify, NSEvent.scrollWheel) and forwards events to
the renderer. It's autolinked via react-native-jsoncanvas.podspec —
the only thing you do as a consumer is run pod install.
cd macos && pod installAfter that, the canvas gets two-finger pan and Smart Zoom for free.
There is no JS-side opt-in, no <GestureHandlerRootView> requirement
beyond what RNGH already needs, and no platform flag to flip.
If your app already ships its own scroll-wheel native module (Workspace
does — its bridge is scoped to a specific NSView so the sidebar
doesn't double-trigger), CanvasView will prefer it. The convention is:
- Register a
RCTEventEmitternamedScrollWheelBridge. - Emit
onScrollWheelevents with{ deltaX: number, deltaY: number }.
When present, the library's own scroll-wheel monitor stays installed but the renderer listens to your bridge instead. You don't need to disable anything; it's a runtime preference.
Linux (GTK4 + libadwaita, via GTKX)
GTK can't host React Native or Skia, so Linux has its own renderer under
the ./gtk entry point: the same core document model, drawn with Cairo
on a GtkDrawingArea.
import {CanvasView} from '@workspace.sh/react-native-jsoncanvas/gtk';
<CanvasView source={canvasJson} label="Board" />;- Gestures: scroll pans, Ctrl+scroll zooms about the pointer, drag
pans, pinch zooms. Recentre and fit buttons sit in the bottom trailing
corner, and the last of the two is re-applied when the view resizes or
sidebarShownchanges. - Camera:
initialCamerarestores a saved view;onCameraChangereports where a gesture or a button left it, once per gesture. - Colour scheme: pass
colorScheme('light'or'dark') as you would to the React NativeCanvasView, or leave it out and the canvas follows libadwaita'sAdw.StyleManager, live. - Draws: text, file, link and group nodes as coloured cards with their text, file name or URL; edges with an arrowhead. Not yet: markdown in text nodes, images, Canvas Candy, the minimap, edge labels and end styles.
Peers: @gtkx/react and @gtkx/cairo (optional, so non-GTK installs
don't fetch them), plus the app's GTKX-generated @gtkx/gi and
@gtkx/jsx bindings with Adw-1 bound. Nothing in this repo typechecks
or tests the GTK code, because the bindings are generated in the app;
the app that consumes it does both.
If you link a checkout of this repo rather than installing it, its own
node_modules (React included) sits nearer the source than your app's.
Point those peers back at the app: in Vite,
resolve.dedupe: ['react', '@gtkx/react', '@gtkx/cairo', '@gtkx/gi', '@gtkx/jsx'],
and the matching paths in tsconfig.json.
The library exposes three layers:
- Core — pure-TS JSON Canvas parser, serialiser, spatial index, and stateful document model. No React, no rendering.
- Renderer — Skia-based React Native components (
CanvasView,SkiaCanvasLayer, node and edge renderers) with built-in pan / pinch / double-tap gestures. - GTK renderer (
./gtk) — Cairo drawing and a GTKXCanvasViewfor GTK4 and libadwaita apps on Linux, on the same core.
The renderer is consumed through CanvasView. The core surface (parseCanvas, createCanvasState, etc.) is exported alongside if you need to inspect or mutate documents independently.
In the React Native renderer, text-node bodies are parsed with unified /
remark (markdown/parseToSegments.ts) and drawn through Skia's paragraph
API (paragraphBuilder.ts), not as native React text components. Going
through Skia is intentional: it bypasses a react-native-macos rendering
limitation where native Text stops drawing beyond ~1500px from the parent
view's origin, which would otherwise punch holes in any sufficiently large
canvas. (The GTK renderer doesn't draw markdown yet; see Linux.)
Drawn:
- Inline: bold, italic, code spans, strikethrough, and link text (styled, not tappable)
- Blocks: paragraphs, headings, ordered and unordered lists, code blocks (monospace, no syntax highlighting), blockquotes, and the text content of HTML
- YAML frontmatter is parsed and stripped: the Canvas Candy extension layer
reads its
cssclasses, and it is not drawn as content
Not drawn yet:
- Tables and images are dropped
- Nested list items are dropped; only the top level of a list draws
- Task-list items draw as plain bullets, without a checkbox
Closing the gap with a markdown document is tracked in #88.
npm install
npm test # jest — runs src/**/__tests__
npm run typecheck # tsc --noEmit
npm run lint # eslint srcTests use a separate tsconfig.test.json so the library's main tsconfig.json stays free of jest / node types.
None of these reach src/gtk/: GTKX generates its bindings inside the app
that uses them, so the GTK renderer is typechecked and tested by its
consumer (Workspace's Linux client). See AGENTS.md.
Example harnesses live under example/ and follow the org's standard layout
(mirrors react-native-source-editor/example/*):
example/expo-app/— Expo SDK 55 host serving iOS + Android. Uses Expo CNG, owned byexpo prebuild— never runpod install(iOS) or hand-editandroid/here manually.example/macos-app/—react-native-macos0.81 host. Tracked in #21.
Each example imports the library through Metro's extraNodeModules mapping
back to repo root. Edits to src/ hot-reload via Metro's watcher.
All commands run from the repo root. Scripts follow
<form-factor>:<platform>:<mode> — Metro and dep-install are
platform-agnostic (Metro is just a JS bundler), so they collapse to
the form-factor level; build/run is platform-specific.
# Shared across iOS and Android (one expo-app, one Metro bundle)
npm run mobile:install # npm install for example/expo-app
npm run mobile:start # Metro only (after a build exists)
npm run mobile:clear # watchman + Metro --reset-cache
# iOS
npm run mobile:ios:dev # concurrently: mobile:clear + mobile:ios:run
npm run mobile:ios:run # Xcode build + sim launch
npm run mobile:ios:run:device # tethered iOS device
npm run mobile:ios:run:device:release # release on tethered device
npm run mobile:ios:prebuild # regenerate ios/ from app.json (CNG)
npm run mobile:ios:clean # delete ios/ — pair with :prebuild
# Android (same surface)
npm run mobile:android:dev
npm run mobile:android:run
npm run mobile:android:run:device
npm run mobile:android:prebuild
npm run mobile:android:cleanA bare-RN + react-native-macos harness lives at example/macos-app/.
Sibling to the Expo playground; same CanvasView + Fit / Recenter shape
and same hesprs-demo fixture, but in a vanilla macOS window rather
than Expo.
The native macos/ scaffold ships with the repo (lifted from the
known-good enriched-markdown-macos-harness
template — see example/macos-app/README.md for the why). No
react-native-macos-init step required.
From the repo root:
# Shared across all desktop platforms (only macos today)
npm run desktop:install # npm install for example/macos-app (--legacy-peer-deps)
npm run desktop:start # Metro only on port 8083
npm run desktop:clear # watchman + Metro --reset-cache
# macOS
npm run desktop:macos:dev # concurrently: desktop:clear + desktop:macos:run
npm run desktop:macos:run # xcodebuild + launch (Metro must be running)
npm run desktop:macos:pods # pod install inside macos/
npm run desktop:macos:clean # rm macos/build + macos/Pods (cold rebuild)The full first-time setup, in order:
npm run desktop:install # postinstall: symlink react-native into example/macos-app
npm run desktop:macos:pods # generate Pods/ from the Podfile
npm run desktop:macos:dev # Metro + xcodebuild + launchPort 8083 matches Workspace's desktop:* convention, leaving 8082
free for the Expo playground when both are running. React-version
isolation is handled in example/macos-app/metro.config.js —
react-native-macos@0.81 pins react@19.1.4 exact, the Expo playground
uses react@19.2.0, and Metro forces this app's local copy to win every
resolution.