Skip to content

About

React Native JSON Canvas renderer (Skia), also on Linux through GTKX; editor TBD

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@workspace.sh/react-native-jsoncanvas

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.

Install

npm install @workspace.sh/react-native-jsoncanvas

Peer dependencies for React Native (you provide these in your app; for Linux, see Linux):

  • react
  • react-native
  • @shopify/react-native-skia
  • react-native-gesture-handler
  • react-native-reanimated
  • react-native-worklets

Usage

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.

Platforms & gestures

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) —

iOS / Android (Expo or bare RN)

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-worklets

Expo: 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.

macOS (react-native-macos)

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 install

After 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.

Escape hatch: bring-your-own scroll bridge

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 RCTEventEmitter named ScrollWheelBridge.
  • Emit onScrollWheel events 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 sidebarShown changes.
  • Camera: initialCamera restores a saved view; onCameraChange reports where a gesture or a button left it, once per gesture.
  • Colour scheme: pass colorScheme ('light' or 'dark') as you would to the React Native CanvasView, or leave it out and the canvas follows libadwaita's Adw.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.

What's inside

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 GTKX CanvasView for 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.

Markdown in text nodes

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.

Development

npm install
npm test          # jest — runs src/**/__tests__
npm run typecheck # tsc --noEmit
npm run lint      # eslint src

Tests 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 apps

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 by expo prebuild — never run pod install (iOS) or hand-edit android/ here manually.
  • example/macos-app/ — react-native-macos 0.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:clean

Playground app (macOS)

A 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 + launch

Port 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.

About

React Native JSON Canvas renderer (Skia), also on Linux through GTKX; editor TBD

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages