Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Production-grade React Native ink primitives extracted from the MathNotes canvas
<img src="https://raw.githubusercontent.com/mathnotes-app/mobile-ink/main/docs/assets/mobile-ink-cover.jpg" alt="Mobile Ink cover drawn inside the example canvas" width="680" />
</p>

`@mathnotes/mobile-ink` is an iOS-first native drawing engine for React Native apps. It gives you Apple Pencil input, Skia/Metal rendering, stroke serialization, selection, zoom, momentum scrolling, and a continuous notebook surface backed by a fixed native engine pool.
`@mathnotes/mobile-ink` is a native drawing engine for React Native apps. It gives you Apple Pencil and stylus input, Skia-backed native rendering, stroke serialization, selection, zoom, momentum scrolling, and a continuous notebook surface backed by a fixed native engine pool.

## A Note To Contributors

Expand All @@ -37,12 +37,12 @@ mobile-ink is currently used in production in MathNotes: https://apps.apple.com/
| Area | Current support |
| --- | --- |
| iOS Apple Pencil drawing | Used in production |
| Native rendering | Custom `MTKView` backed by C++ Skia/Metal |
| Android stylus/finger drawing | Native Android view backed by the same C++ engine |
| Native rendering | iOS `MTKView` with Skia/Metal; Android `GLSurfaceView` with Skia/OpenGL upload |
| Continuous notebooks | Fixed native engine pool with momentum scroll and pinch zoom |
| Tools | Pen, highlighter, crayon, calligraphy, eraser, selection, and shape recognition |
| Serialization | JSON notebook payloads plus native page load/save/export helpers |
| Example app | Expo dev-client app with blank continuous notebook, tools, selection, save/reload, and zoom |
| Android | Not supported yet |
| Expo Go | Not supported because this package includes native code |

## Demos
Expand All @@ -68,7 +68,7 @@ npm install @mathnotes/mobile-ink \
cd ios && pod install
```

For Expo apps, use a dev client or prebuild. Expo Go cannot load this native module.
For Expo apps, use a dev client or prebuild. Expo Go cannot load this native module. Android consumers need the normal React Native Android toolchain because the package builds a Kotlin/C++ Android library.

Your app Babel config must include the Reanimated/Worklets plugin expected by your React Native/Reanimated version. For Expo SDK 54/Reanimated 4:

Expand Down Expand Up @@ -121,7 +121,7 @@ export function Notebook() {

## Public Surface

- `NativeInkCanvas`: low-level native Skia/Metal drawing view.
- `NativeInkCanvas`: low-level native Skia drawing view.
- `ZoomableInkViewport`: production pinch, pan, momentum, focal-point zoom, and Apple Pencil/finger gesture routing.
- `ContinuousEnginePool`: fixed-size native canvas pool for continuous notebooks.
- `InfiniteInkCanvas`: full vertical continuous notebook shell with pooled native engines, trailing page creation, dirty-page serialization, zoom, momentum scroll, and generic page backgrounds.
Expand All @@ -144,6 +144,12 @@ For a simulator:
npx expo run:ios
```

For Android:

```sh
npx expo run:android
```

## Documentation

- [Architecture](docs/architecture.md)
Expand All @@ -159,7 +165,7 @@ Near-term work is focused on making the public package easier to adopt and easie
- Tighten selection transform performance for large stroke groups.
- Improve edge-case zoom behavior near page and canvas boundaries.
- Continue hardening the example app as a small regression harness.
- Explore Android after the iOS API surface has settled.
- Keep Android behavior aligned with the iOS API surface as new native capabilities land.

## Development

Expand Down
2 changes: 2 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.cxx/
/build/
92 changes: 92 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
buildscript {
def kotlinVersion = rootProject.ext.has("kotlinVersion")
? rootProject.ext.get("kotlinVersion")
: "2.1.20"

repositories {
google()
mavenCentral()
}

dependencies {
classpath("com.android.tools.build:gradle:8.10.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}

def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}

static def findNodeModules(baseDir) {
def basePath = baseDir.toPath().normalize()
while (basePath != null) {
def nodeModulesPath = basePath.resolve("node_modules").toFile()
def reactNativePath = new File(nodeModulesPath, "react-native")
if (nodeModulesPath.exists() && reactNativePath.exists()) {
return nodeModulesPath.absolutePath
}
basePath = basePath.getParent()
}
throw new GradleException("@mathnotes/mobile-ink: failed to find node_modules")
}

def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}

apply plugin: "com.android.library"
apply plugin: "kotlin-android"

def nodeModules = findNodeModules(projectDir)

android {
namespace "com.mathnotes.mobileink"
compileSdkVersion safeExtGet("compileSdkVersion", 35)

if (rootProject.ext.has("ndkPath")) {
ndkPath rootProject.ext.ndkPath
}
if (rootProject.ext.has("ndkVersion")) {
ndkVersion rootProject.ext.ndkVersion
}

defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 35)

externalNativeBuild {
cmake {
cppFlags "-std=c++17", "-frtti", "-fexceptions", "-DSK_BUILD_FOR_ANDROID", "-DON_ANDROID", "-DONANDROID"
arguments "-DNODE_MODULES_DIR=${nodeModules}",
"-DANDROID_STL=c++_shared",
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
abiFilters(*reactNativeArchitectures())
}
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.22.1"
}
}

packagingOptions {
excludes = [
"**/libc++_shared.so",
"META-INF/**"
]
}
}

dependencies {
implementation "com.facebook.react:react-android"
}
1 change: 1 addition & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
64 changes: 64 additions & 0 deletions android/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
cmake_minimum_required(VERSION 3.13)
project(mobileink)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Node modules path passed from Gradle
set(NODE_MODULES_DIR ${NODE_MODULES_DIR})

# Skia paths from react-native-skia
set(SKIA_DIR "${NODE_MODULES_DIR}/@shopify/react-native-skia")
set(SKIA_INCLUDE_DIR "${SKIA_DIR}/cpp/skia")
set(SKIA_LIB_DIR "${SKIA_DIR}/libs/android/${ANDROID_ABI}")

# Shared C++ drawing engine from this package root.
set(NATIVE_DRAWING_CPP_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../cpp")

message(STATUS "mobileink: NODE_MODULES_DIR=${NODE_MODULES_DIR}")
message(STATUS "mobileink: SKIA_DIR=${SKIA_DIR}")
message(STATUS "mobileink: NATIVE_DRAWING_CPP_DIR=${NATIVE_DRAWING_CPP_DIR}")
message(STATUS "mobileink: ANDROID_ABI=${ANDROID_ABI}")

# Include directories
include_directories(
${SKIA_INCLUDE_DIR}
${SKIA_INCLUDE_DIR}/include
${SKIA_INCLUDE_DIR}/modules/pathops/include
${NATIVE_DRAWING_CPP_DIR}
)

# Add source files
add_library(
mobileink
SHARED
${NATIVE_DRAWING_CPP_DIR}/SkiaDrawingEngine.cpp
${NATIVE_DRAWING_CPP_DIR}/DrawingSelection.cpp
${NATIVE_DRAWING_CPP_DIR}/BackgroundRenderer.cpp
${NATIVE_DRAWING_CPP_DIR}/DrawingSerialization.cpp
${NATIVE_DRAWING_CPP_DIR}/PathRenderer.cpp
${NATIVE_DRAWING_CPP_DIR}/EraserRenderer.cpp
${NATIVE_DRAWING_CPP_DIR}/StrokeSplitter.cpp
${NATIVE_DRAWING_CPP_DIR}/BatchExporter.cpp
${NATIVE_DRAWING_CPP_DIR}/ActiveStrokeRenderer.cpp
jni_bridge.cpp
)

# Link Skia library
add_library(skia STATIC IMPORTED)
set_target_properties(skia PROPERTIES IMPORTED_LOCATION "${SKIA_LIB_DIR}/libskia.a")

add_library(pathops STATIC IMPORTED)
set_target_properties(pathops PROPERTIES IMPORTED_LOCATION "${SKIA_LIB_DIR}/libpathops.a")

# Link libraries
target_link_libraries(
mobileink
skia
pathops
android
log
GLESv2
EGL
jnigraphics
)
Loading
Loading