From afda771199387749729a3997836e03b559769749 Mon Sep 17 00:00:00 2001 From: markm39 Date: Mon, 12 Oct 2026 06:56:43 -0500 Subject: [PATCH 1/3] feat(ink): preserve continuous content and bound zoom rendering --- .github/workflows/ci.yml | 12 + CHANGELOG.md | 9 + android/build.gradle | 1 + android/consumer-rules.pro | 3 + android/src/main/cpp/CMakeLists.txt | 1 + android/src/main/cpp/document_bridge.cpp | 130 +++++++ android/src/main/cpp/jni_bridge.cpp | 64 +++- .../mobileink/MobileInkCanvasView.kt | 133 +++++-- .../mobileink/MobileInkCanvasViewManager.kt | 8 + .../mobileink/MobileInkDocumentBridge.kt | 155 +++++++++ .../mathnotes/mobileink/MobileInkModule.kt | 22 ++ .../mathnotes/mobileink/MobileInkPackage.kt | 10 +- cpp/ActiveStrokeRenderer.cpp | 55 +-- cpp/ActiveStrokeRenderer.h | 14 +- cpp/ContinuousDocumentPages.cpp | 145 ++++++++ cpp/ContinuousDocumentPages.h | 23 ++ cpp/DrawingHistory.cpp | 4 + cpp/DrawingTypes.h | 3 + cpp/SkiaDrawingEngine.cpp | 75 ++-- cpp/SkiaDrawingEngine.h | 30 +- cpp/SkiaDrawingEngineDocument.cpp | 45 +++ cpp/SkiaDrawingEngineEraser.cpp | 2 +- cpp/SkiaDrawingEngineRendering.cpp | 309 +---------------- cpp/SkiaDrawingEngineSelection.cpp | 2 +- cpp/SkiaDrawingEngineZoomRendering.cpp | 328 ++++++++++++++++++ .../MobileInkBackgroundView.swift | 2 + .../MobileInkBridge+NotebookParsing.swift | 27 ++ ios/MobileInkModule/MobileInkBridge.m | 4 + ios/MobileInkModule/MobileInkBridge.mm | 46 ++- ios/MobileInkModule/MobileInkCanvasView.swift | 223 ++++++++---- .../MobileInkCanvasViewManager.m | 3 + .../MobileInkCanvasViewManager.swift | 33 +- .../MobileInkDocumentBridge.mm | 176 ++++++++++ ios/MobileInkModule/SkiaEngineBridge.swift | 26 +- package-lock.json | 4 +- package.json | 11 +- scripts/continuous_document_benchmark.mm | 52 +++ scripts/document-jni/DocumentCodec.java | 8 + scripts/document-jni/DocumentCodecSmoke.java | 47 +++ scripts/document-jni/DocumentPageData.java | 10 + scripts/document-jni/README.md | 9 + scripts/document-jni/fixture.cpp | 44 +++ scripts/engine_memory_benchmark.mm | 30 ++ scripts/selection_history_smoke.cpp | 51 +++ scripts/test-document-jni.sh | 21 ++ scripts/zoom_render_benchmark.mm | 133 +++++++ scripts/zoom_surface_smoke.mm | 277 +++++++++++++++ src/ContinuousEnginePool.tsx | 28 +- src/InfiniteInkCanvas.tsx | 30 +- src/NativeInkCanvas.tsx | 40 ++- src/ZoomableInkViewport.tsx | 67 ++-- src/__tests__/ContinuousEnginePool.test.tsx | 94 ++++- src/__tests__/PageStack.test.tsx | 10 +- src/__tests__/assignmentQueue.test.ts | 51 +++ src/__tests__/serialization.test.ts | 1 + .../PooledCanvasSlot.tsx | 102 ++---- src/continuous-engine-pool/README.md | 34 ++ .../__tests__/renderViewport.test.ts | 49 +++ src/continuous-engine-pool/assignmentQueue.ts | 39 +++ src/continuous-engine-pool/renderViewport.ts | 40 +++ src/continuous-engine-pool/types.ts | 5 +- src/index.ts | 6 + .../ContinuousDocumentCanvas.tsx | 261 ++++++++++++++ src/infinite-ink-canvas/PageStack.tsx | 28 +- src/infinite-ink-canvas/README.md | 5 + .../ContinuousDocumentCanvas.test.tsx | 183 ++++++++++ src/infinite-ink-canvas/types.ts | 2 + src/native-ink-canvas/README.md | 25 ++ .../__tests__/notebookParsing.test.ts | 27 ++ src/native-ink-canvas/documentBridge.ts | 29 ++ src/native-ink-canvas/notebookBridge.ts | 13 + src/native-ink-canvas/types.ts | 9 +- src/payload.ts | 3 +- src/utils/README.md | 9 + src/utils/__tests__/documentViewport.test.ts | 26 ++ src/utils/__tests__/trimIfNeeded.test.ts | 21 ++ src/utils/documentViewport.ts | 26 ++ src/utils/pageGrowth.ts | 5 +- src/utils/serialization.ts | 11 +- src/utils/trimIfNeeded.ts | 12 + src/zoomable-ink-viewport/README.md | 24 ++ .../__tests__/tapCoordinates.test.ts | 14 + .../__tests__/transitionCompletion.test.ts | 37 ++ .../__tests__/useViewportTransition.test.ts | 89 +++++ .../__tests__/viewportCallbacks.test.ts | 38 ++ src/zoomable-ink-viewport/geometry.ts | 11 + .../transitionCompletion.ts | 19 + src/zoomable-ink-viewport/types.ts | 4 + .../useViewportTransition.ts | 77 ++++ .../useZoomableViewportGestures.ts | 75 ++-- .../viewportCallbacks.ts | 23 ++ 91 files changed, 3802 insertions(+), 720 deletions(-) create mode 100644 android/consumer-rules.pro create mode 100644 android/src/main/cpp/document_bridge.cpp create mode 100644 android/src/main/java/com/mathnotes/mobileink/MobileInkDocumentBridge.kt create mode 100644 cpp/ContinuousDocumentPages.cpp create mode 100644 cpp/ContinuousDocumentPages.h create mode 100644 cpp/SkiaDrawingEngineDocument.cpp create mode 100644 cpp/SkiaDrawingEngineZoomRendering.cpp create mode 100644 ios/MobileInkModule/MobileInkBridge+NotebookParsing.swift create mode 100644 ios/MobileInkModule/MobileInkDocumentBridge.mm create mode 100644 scripts/continuous_document_benchmark.mm create mode 100644 scripts/document-jni/DocumentCodec.java create mode 100644 scripts/document-jni/DocumentCodecSmoke.java create mode 100644 scripts/document-jni/DocumentPageData.java create mode 100644 scripts/document-jni/README.md create mode 100644 scripts/document-jni/fixture.cpp create mode 100644 scripts/engine_memory_benchmark.mm create mode 100644 scripts/test-document-jni.sh create mode 100644 scripts/zoom_render_benchmark.mm create mode 100644 scripts/zoom_surface_smoke.mm create mode 100644 src/__tests__/assignmentQueue.test.ts create mode 100644 src/continuous-engine-pool/README.md create mode 100644 src/continuous-engine-pool/__tests__/renderViewport.test.ts create mode 100644 src/continuous-engine-pool/assignmentQueue.ts create mode 100644 src/continuous-engine-pool/renderViewport.ts create mode 100644 src/infinite-ink-canvas/ContinuousDocumentCanvas.tsx create mode 100644 src/infinite-ink-canvas/README.md create mode 100644 src/infinite-ink-canvas/__tests__/ContinuousDocumentCanvas.test.tsx create mode 100644 src/native-ink-canvas/README.md create mode 100644 src/native-ink-canvas/__tests__/notebookParsing.test.ts create mode 100644 src/native-ink-canvas/documentBridge.ts create mode 100644 src/utils/README.md create mode 100644 src/utils/__tests__/documentViewport.test.ts create mode 100644 src/utils/__tests__/trimIfNeeded.test.ts create mode 100644 src/utils/documentViewport.ts create mode 100644 src/utils/trimIfNeeded.ts create mode 100644 src/zoomable-ink-viewport/README.md create mode 100644 src/zoomable-ink-viewport/__tests__/tapCoordinates.test.ts create mode 100644 src/zoomable-ink-viewport/__tests__/transitionCompletion.test.ts create mode 100644 src/zoomable-ink-viewport/__tests__/useViewportTransition.test.ts create mode 100644 src/zoomable-ink-viewport/__tests__/viewportCallbacks.test.ts create mode 100644 src/zoomable-ink-viewport/transitionCompletion.ts create mode 100644 src/zoomable-ink-viewport/useViewportTransition.ts create mode 100644 src/zoomable-ink-viewport/viewportCallbacks.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e25365..76f9e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,18 @@ jobs: - name: Run selection history regression test run: npm run test:native:selection-smoke + - name: Run zoom surface regression test + run: npm run test:native:zoom-smoke + + - name: Set up Java for document JNI regression test + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Run continuous document JNI regression test + run: npm run test:native:document-jni + - name: Build package artifacts run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 18938ec..5994af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to `@mathnotes/mobile-ink` will be documented here. +## [0.3.8] - 2026-10-12 + +- Added continuous document drawing across page boundaries, preserving stroke order, eraser targets, undo/redo, and page-local save/export data. +- Added external edit history for host-owned text and image changes so their undo/redo remains chronological with ink. +- Render visible zoom regions at the required scale and release obsolete render surfaces; avoid retaining old notebooks in native gesture callbacks. +- Correct tap coordinates in centered zoomed viewports and confirm native loads before marking pages ready. +- Added native parsing of the exact selected notebook payload so recovery does not reread a stale local file. +- Ordinary multi-page notebooks no longer acquire inferred PDF page numbers during loading. + ## [0.3.7] - 2026-10-02 - Added `capturePreviewOnReassign` to `ContinuousEnginePool` (default `true`). Hosts that don't use page previews can set it to `false` to skip the native render and PNG encode that runs for every captured page when the pool shifts. Page data is still captured. diff --git a/android/build.gradle b/android/build.gradle index b5793ef..084e1ba 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -53,6 +53,7 @@ android { } defaultConfig { + consumerProguardFiles "consumer-rules.pro" minSdkVersion safeExtGet("minSdkVersion", 24) targetSdkVersion safeExtGet("targetSdkVersion", 35) diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 0000000..3756017 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,3 @@ +# JNI constructs page results and resolves these native entry points by name. +-keep class com.mathnotes.mobileink.DocumentPageData { *; } +-keep class com.mathnotes.mobileink.DocumentCodec { *; } diff --git a/android/src/main/cpp/CMakeLists.txt b/android/src/main/cpp/CMakeLists.txt index e17d059..c3a4edb 100644 --- a/android/src/main/cpp/CMakeLists.txt +++ b/android/src/main/cpp/CMakeLists.txt @@ -38,6 +38,7 @@ add_library( SHARED ${NATIVE_DRAWING_SOURCES} jni_bridge.cpp + document_bridge.cpp ) # Link Skia library diff --git a/android/src/main/cpp/document_bridge.cpp b/android/src/main/cpp/document_bridge.cpp new file mode 100644 index 0000000..b6329fb --- /dev/null +++ b/android/src/main/cpp/document_bridge.cpp @@ -0,0 +1,130 @@ +#include +#include "ContinuousDocumentPages.h" +#include "DrawingSerialization.h" +#include +#include + +using namespace nativedrawing; +namespace { +void check(JNIEnv* env) { + if (env->ExceptionCheck()) throw std::runtime_error("Document JNI allocation failed"); +} +ContinuousDocumentPage decode(JNIEnv* env, jbyteArray data, jlongArray order, bool hasOrder) { + if (!data || !order) throw std::invalid_argument("Missing document page data"); + ContinuousDocumentPage page; + std::vector bytes(env->GetArrayLength(data)); + if (!bytes.empty()) env->GetByteArrayRegion(data, 0, bytes.size(), reinterpret_cast(bytes.data())); + check(env); + if (!bytes.empty()) { + DrawingSerialization serializer; + if (!serializer.deserialize(bytes, page.strokes)) throw std::invalid_argument("Invalid serialized drawing"); + } + const auto count = env->GetArrayLength(order); + if (hasOrder && static_cast(count) != page.strokes.size()) + throw std::invalid_argument("Document stroke order count mismatch"); + if (!hasOrder && count) throw std::invalid_argument("Unexpected document stroke order"); + std::vector values(count); + if (count) env->GetLongArrayRegion(order, 0, count, values.data()); + check(env); + for (auto value : values) { + if (value < 0 || value > 9007199254740991LL) throw std::invalid_argument("Invalid document stroke order"); + page.order.push_back(static_cast(value)); + } + return page; +} +std::vector decodePages(JNIEnv* env, jobjectArray data, jobjectArray orders, jbooleanArray flags) { + if (!data || !orders || !flags) throw std::invalid_argument("Missing document pages"); + const auto count = env->GetArrayLength(data); + if (env->GetArrayLength(orders) != count || env->GetArrayLength(flags) != count) + throw std::invalid_argument("Document page count mismatch"); + std::vector hasOrder(count); + if (count) env->GetBooleanArrayRegion(flags, 0, count, hasOrder.data()); + check(env); + std::vector result; + result.reserve(count); + for (jsize index = 0; index < count; ++index) { + // Bound local JNI references even for very long documents. + if (env->PushLocalFrame(4) != JNI_OK) throw std::runtime_error("Cannot allocate document JNI frame"); + try { + auto bytes = static_cast(env->GetObjectArrayElement(data, index)); + auto order = static_cast(env->GetObjectArrayElement(orders, index)); + check(env); + result.push_back(decode(env, bytes, order, hasOrder[index])); + } catch (...) { env->PopLocalFrame(nullptr); throw; } + env->PopLocalFrame(nullptr); + } + return result; +} +jbyteArray encodeBytes(JNIEnv* env, const std::vector& strokes) { + DrawingSerialization serializer; + const auto bytes = strokes.empty() ? std::vector() : serializer.serialize(strokes); + if (bytes.size() > static_cast(std::numeric_limits::max())) + throw std::length_error("Document payload exceeds JNI array limit"); + auto result = env->NewByteArray(static_cast(bytes.size())); + check(env); + if (!bytes.empty()) env->SetByteArrayRegion(result, 0, bytes.size(), reinterpret_cast(bytes.data())); + check(env); + return result; +} +jobjectArray encodePages(JNIEnv* env, const std::vector& pages) { + auto type = env->FindClass("com/mathnotes/mobileink/DocumentPageData"); + check(env); + auto constructor = env->GetMethodID(type, "", "([B[J)V"); + check(env); + auto result = env->NewObjectArray(pages.size(), type, nullptr); + check(env); + for (size_t index = 0; index < pages.size(); ++index) { + if (env->PushLocalFrame(4) != JNI_OK) throw std::runtime_error("Cannot allocate document JNI frame"); + try { + const auto& page = pages[index]; + auto bytes = encodeBytes(env, page.strokes); + std::vector order(page.order.begin(), page.order.end()); + auto indices = env->NewLongArray(order.size()); + check(env); + if (!order.empty()) env->SetLongArrayRegion(indices, 0, order.size(), order.data()); + check(env); + auto value = env->NewObject(type, constructor, bytes, indices); + check(env); + env->SetObjectArrayElement(result, index, value); + check(env); + } catch (...) { env->PopLocalFrame(nullptr); throw; } + env->PopLocalFrame(nullptr); + } + env->DeleteLocalRef(type); + return result; +} +void reject(JNIEnv* env, const std::exception& error) { + if (env->ExceptionCheck()) return; + auto type = env->FindClass("java/lang/IllegalArgumentException"); + if (type) { env->ThrowNew(type, error.what()); env->DeleteLocalRef(type); } +} +} +extern "C" JNIEXPORT jbyteArray JNICALL Java_com_mathnotes_mobileink_DocumentCodec_compose( + JNIEnv* env, jobject, jobjectArray data, jobjectArray orders, jbooleanArray flags, jfloat height) { + try { return encodeBytes(env, composeContinuousDocument(decodePages(env, data, orders, flags), height)); } + catch (const std::exception& error) { reject(env, error); return nullptr; } + catch (...) { reject(env, std::runtime_error("Unknown document codec failure")); return nullptr; } +} +extern "C" JNIEXPORT jobjectArray JNICALL Java_com_mathnotes_mobileink_DocumentCodec_partition( + JNIEnv* env, jobject, jbyteArray data, jlongArray order, jboolean hasOrder, jint count, jfloat height) { + try { + if (count < 1 || count > 100000) throw std::invalid_argument("Invalid document page count"); + return encodePages(env, partitionContinuousDocument(decode(env, data, order, hasOrder).strokes, count, height)); + } catch (const std::exception& error) { reject(env, error); return nullptr; } + catch (...) { reject(env, std::runtime_error("Unknown document codec failure")); return nullptr; } +} +extern "C" JNIEXPORT jobjectArray JNICALL Java_com_mathnotes_mobileink_DocumentCodec_project( + JNIEnv* env, jobject, jobjectArray data, jobjectArray orders, jbooleanArray flags, jfloat height, jintArray requested) { + try { + if (!requested) throw std::invalid_argument("Missing export page indices"); + std::vector raw(env->GetArrayLength(requested)); + if (!raw.empty()) env->GetIntArrayRegion(requested, 0, raw.size(), raw.data()); + check(env); + const auto projected = projectContinuousDocumentPages(decodePages(env, data, orders, flags), height, + std::vector(raw.begin(), raw.end())); + std::vector pages; + for (const auto& strokes : projected) pages.push_back({strokes, {}}); + return encodePages(env, pages); + } catch (const std::exception& error) { reject(env, error); return nullptr; } + catch (...) { reject(env, std::runtime_error("Unknown document codec failure")); return nullptr; } +} diff --git a/android/src/main/cpp/jni_bridge.cpp b/android/src/main/cpp/jni_bridge.cpp index cca73f2..0faece8 100644 --- a/android/src/main/cpp/jni_bridge.cpp +++ b/android/src/main/cpp/jni_bridge.cpp @@ -117,7 +117,7 @@ sk_sp makeRedBlueSwappedImage(const sk_sp& image) { return surface->makeImageSnapshot(); } -void renderWithDisplayColorOrdering(SkiaDrawingEngine* engine, SkCanvas* canvas) { +void renderWithDisplayColorOrdering(SkiaDrawingEngine* engine, SkCanvas* canvas, bool cropped = false) { if (!engine || !canvas) { return; } @@ -125,7 +125,8 @@ void renderWithDisplayColorOrdering(SkiaDrawingEngine* engine, SkCanvas* canvas) SkPaint outputPaint; outputPaint.setColorFilter(makeRedBlueSwapFilter()); canvas->saveLayer(nullptr, &outputPaint); - engine->render(canvas); + if (cropped) engine->renderVisibleViewport(canvas); + else engine->render(canvas); canvas->restore(); } @@ -356,8 +357,9 @@ struct DrawingContext { int ganeshSamples = -1; int ganeshStencil = -1; - DrawingContext(int w, int h) { - engine = std::make_unique(w, h); + const bool continuousDocument; + DrawingContext(int w, int h, bool continuous = false) : continuousDocument(continuous) { + engine = std::make_unique(w, h, continuous); } ~DrawingContext() { @@ -374,12 +376,19 @@ extern "C" { // Engine lifecycle JNIEXPORT jlong JNICALL Java_com_mathnotes_mobileink_MobileInkCanvasView_createDrawingEngine( - JNIEnv* env, jobject obj, jint width, jint height) { + JNIEnv* env, jobject obj, jint width, jint height, jboolean continuous) { - auto* ctx = new DrawingContext(width, height); + auto* ctx = new DrawingContext(width, height, continuous == JNI_TRUE); return reinterpret_cast(ctx); } +JNIEXPORT jboolean JNICALL +Java_com_mathnotes_mobileink_MobileInkCanvasView_resizeContinuousDrawingEngine( + JNIEnv* env, jobject obj, jlong contextPtr, jint width, jint height) { + auto* ctx = reinterpret_cast(contextPtr); + return ctx && ctx->engine && ctx->engine->resizeDocument(width, height) ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT void JNICALL Java_com_mathnotes_mobileink_MobileInkCanvasView_destroyDrawingEngine( JNIEnv* env, jobject obj, jlong contextPtr) { @@ -461,6 +470,12 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_updateHoldShapePreview( } // Canvas operations +JNIEXPORT jboolean JNICALL +Java_com_mathnotes_mobileink_MobileInkCanvasView_clearDocumentPageNative(JNIEnv* env, jobject obj, jlong contextPtr, jint pageIndex, jfloat pageHeight) { + auto* ctx = reinterpret_cast(contextPtr); + return ctx && ctx->engine && ctx->engine->clearDocumentPage(pageIndex, pageHeight) ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT void JNICALL Java_com_mathnotes_mobileink_MobileInkCanvasView_clearCanvas(JNIEnv* env, jobject obj, jlong contextPtr) { auto* ctx = reinterpret_cast(contextPtr); @@ -469,20 +484,35 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_clearCanvas(JNIEnv* env, jobjec } } -JNIEXPORT void JNICALL -Java_com_mathnotes_mobileink_MobileInkCanvasView_undoStroke(JNIEnv* env, jobject obj, jlong contextPtr) { +JNIEXPORT jboolean JNICALL +Java_com_mathnotes_mobileink_MobileInkCanvasView_recordExternalEditNative( + JNIEnv* env, jobject, jlong contextPtr, jstring before, jstring after) { auto* ctx = reinterpret_cast(contextPtr); - if (ctx && ctx->engine) { - ctx->engine->undo(); - } + if (!ctx || !ctx->engine || !before || !after) return JNI_FALSE; + const char* beforeBytes = env->GetStringUTFChars(before, nullptr); + if (!beforeBytes) return JNI_FALSE; + const char* afterBytes = env->GetStringUTFChars(after, nullptr); + if (!afterBytes) { env->ReleaseStringUTFChars(before, beforeBytes); return JNI_FALSE; } + bool result = ctx->engine->commitExternalEdit(beforeBytes, afterBytes); + env->ReleaseStringUTFChars(before, beforeBytes); + env->ReleaseStringUTFChars(after, afterBytes); + return result ? JNI_TRUE : JNI_FALSE; } -JNIEXPORT void JNICALL -Java_com_mathnotes_mobileink_MobileInkCanvasView_redoStroke(JNIEnv* env, jobject obj, jlong contextPtr) { +JNIEXPORT jstring JNICALL +Java_com_mathnotes_mobileink_MobileInkCanvasView_undoStroke(JNIEnv* env, jobject, jlong contextPtr) { auto* ctx = reinterpret_cast(contextPtr); - if (ctx && ctx->engine) { - ctx->engine->redo(); - } + if (!ctx || !ctx->engine) return nullptr; + const auto payload = ctx->engine->undo(); + return payload.empty() ? nullptr : env->NewStringUTF(payload.c_str()); +} + +JNIEXPORT jstring JNICALL +Java_com_mathnotes_mobileink_MobileInkCanvasView_redoStroke(JNIEnv* env, jobject, jlong contextPtr) { + auto* ctx = reinterpret_cast(contextPtr); + if (!ctx || !ctx->engine) return nullptr; + const auto payload = ctx->engine->redo(); + return payload.empty() ? nullptr : env->NewStringUTF(payload.c_str()); } // Tool settings @@ -883,7 +913,7 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_renderGaneshToCurrentSurface( } SkCanvas* canvas = ctx->ganeshSurface->getCanvas(); - renderWithDisplayColorOrdering(ctx->engine.get(), canvas); + renderWithDisplayColorOrdering(ctx->engine.get(), canvas, ctx->continuousDocument); ctx->ganeshContext->flushAndSubmit(ctx->ganeshSurface.get()); return JNI_TRUE; } diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt index 026e24d..3c984b9 100644 --- a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt @@ -1,6 +1,8 @@ package com.mathnotes.mobileink import android.content.Context +import android.graphics.Matrix +import android.graphics.RectF import android.graphics.Bitmap import android.graphics.SurfaceTexture import android.opengl.EGL14 @@ -72,6 +74,38 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. // When "pencilonly", only stylus touches are processed for drawing var drawingPolicy: String = "default" var renderBackend: String = "ganesh" + var continuousDocument: Boolean = false + set(value) { + if (field == value) return + field = value + queueEvent { + if (drawingEngine != 0L) configureDrawingEngine(engineWidth, engineHeight, true) + } + if (value) refreshContinuousTexture() + else surfaceTexture?.let { if (isAvailable) configureAvailableTexture(it, width, height) } + } + private val effectiveRenderScale: Float + get() = floatArrayOf(1f, 1.5f, 2f, 2.5f, 3f, 4f, 5f).minByOrNull { kotlin.math.abs(it - renderScale) } ?: 1f + + private fun continuousViewport(w: Int = width, h: Int = height): RectF { + val left = renderViewportLeftRatio * w + val top = renderViewportTopRatio * h + val maxSide = 4096f / effectiveRenderScale + return RectF(left, top, + left + maxOf(1f, minOf(w - left, renderViewportWidthRatio * w, maxSide)), + top + maxOf(1f, minOf(h - top, renderViewportHeightRatio * h, maxSide))) + } + + private var continuousTextureUpdatePending = false + private fun refreshContinuousTexture() { + if (!continuousDocument || continuousTextureUpdatePending) return + continuousTextureUpdatePending = true + post { + continuousTextureUpdatePending = false + surfaceTexture?.let { if (isAvailable) configureAvailableTexture(it, width, height) } + } + } + var renderScale: Float = 1f set(value) { val clamped = value.takeIf { it.isFinite() }?.coerceIn(1f, 5f) ?: 1f @@ -88,24 +122,28 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. var renderViewportLeftRatio: Float = 0f set(value) { field = value.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + refreshContinuousTexture() applyRenderViewport() requestInkRender(force = true) } var renderViewportTopRatio: Float = 0f set(value) { field = value.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + refreshContinuousTexture() applyRenderViewport() requestInkRender(force = true) } var renderViewportWidthRatio: Float = 1f set(value) { field = value.takeIf { it.isFinite() }?.coerceIn(0.001f, 1f) ?: 1f + refreshContinuousTexture() applyRenderViewport() requestInkRender(force = true) } var renderViewportHeightRatio: Float = 1f set(value) { field = value.takeIf { it.isFinite() }?.coerceIn(0.001f, 1f) ?: 1f + refreshContinuousTexture() applyRenderViewport() requestInkRender(force = true) } @@ -159,8 +197,16 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. return } - val targetWidth = maxOf(1, kotlin.math.ceil(width * renderScale).toInt()) - val targetHeight = maxOf(1, kotlin.math.ceil(height * renderScale).toInt()) + val visible = if (continuousDocument) continuousViewport(width, height) + else RectF(0f, 0f, width.toFloat(), height.toFloat()) + val transform = Matrix() + if (continuousDocument) { + transform.setScale(visible.width() / width, visible.height() / height) + transform.postTranslate(visible.left, visible.top) + } + setTransform(transform) + val targetWidth = maxOf(1, kotlin.math.ceil(visible.width() * effectiveRenderScale).toInt()) + val targetHeight = maxOf(1, kotlin.math.ceil(visible.height() * effectiveRenderScale).toInt()) surfaceTexture.setDefaultBufferSize(targetWidth, targetHeight) queueEvent { if ( @@ -200,7 +246,13 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. val shouldPreserveDrawing = drawingEngine != 0L if (drawingEngine == 0L || engineWidth != width || engineHeight != height) { - configureDrawingEngine(width, height, shouldPreserveDrawing) + if (continuousDocument && drawingEngine != 0L && resizeContinuousDrawingEngine(drawingEngine, width, height)) { + engineWidth = width + engineHeight = height + applyRenderViewport() + } else { + configureDrawingEngine(width, height, shouldPreserveDrawing) + } } else { applyRenderViewport() } @@ -375,7 +427,7 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. drawingEngine = 0 } - drawingEngine = createDrawingEngine(width, height) + drawingEngine = createDrawingEngine(width, height, continuousDocument) engineWidth = width engineHeight = height @@ -409,10 +461,10 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. } } - private fun applyRenderViewport() { + private fun applyRenderViewport() = queueEvent { val engine = drawingEngine if (engine == 0L || engineWidth <= 0 || engineHeight <= 0) { - return + return@queueEvent } val left = renderViewportLeftRatio.coerceIn(0f, 1f) * engineWidth @@ -426,19 +478,18 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. renderViewportHeightRatio.coerceIn(0.001f, 1f) * engineHeight ) - val actualRenderScale = if (renderWidth > 0 && engineWidth > 0) { + val actualRenderScale = if (continuousDocument) effectiveRenderScale else if (renderWidth > 0 && engineWidth > 0) { (renderWidth.toFloat() / engineWidth.toFloat()).coerceIn(1f, 5f) } else { renderScale.coerceIn(1f, 5f) } + val continuous = if (continuousDocument) continuousViewport(engineWidth, engineHeight) else null setRenderViewport( - engine, - actualRenderScale, - left, - top, - maxOf(1f, viewportWidth), - maxOf(1f, viewportHeight) + engine, actualRenderScale, + continuous?.left ?: left, continuous?.top ?: top, + continuous?.width() ?: maxOf(1f, viewportWidth), + continuous?.height() ?: maxOf(1f, viewportHeight) ) } @@ -1004,6 +1055,18 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. return true } + fun clearDocumentPage(pageIndex: Int, pageHeight: Float) { + val success = runOnGlThreadSync { + drawingEngine != 0L && clearDocumentPageNative(drawingEngine, pageIndex, pageHeight) + } ?: false + if (success) { + resetTransientInteractionState() + requestInkRender() + emitSelectionChange() + sendEvent("onDrawingChange", Arguments.createMap()) + } + } + fun clear() { clearCanvasForLoad() sendEvent("onDrawingChange", Arguments.createMap()) @@ -1038,28 +1101,33 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. requestInkRender() } - fun undo() { - queueEvent { - if (drawingEngine != 0L) { - undoStroke(drawingEngine) - post { emitSelectionChange() } - } - } - requestInkRender() - sendEvent("onDrawingChange", Arguments.createMap()) - } + fun recordExternalEdit(before: String, after: String): Boolean = runOnGlThreadSync { + drawingEngine != 0L && recordExternalEditNative(drawingEngine, before, after) + } ?: false - fun redo() { + private fun applyHistory(redo: Boolean) { queueEvent { if (drawingEngine != 0L) { - redoStroke(drawingEngine) - post { emitSelectionChange() } + val payload = if (redo) redoStroke(drawingEngine) else undoStroke(drawingEngine) + post { + emitSelectionChange() + val event = Arguments.createMap() + event.putString("historyAction", if (redo) "redo" else "undo") + if (payload != null) { + val external = Arguments.createMap() + external.putString("payload", payload) + event.putMap("externalEdit", external) + } + requestInkRender() + sendEvent("onDrawingChange", event) + } } } - requestInkRender() - sendEvent("onDrawingChange", Arguments.createMap()) } + fun undo() = applyHistory(false) + fun redo() = applyHistory(true) + fun setTool(toolType: String, width: Float, color: Int) { cancelHoldToShapePreview() currentToolType = toolType @@ -1349,7 +1417,8 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. } // Native method declarations - private external fun createDrawingEngine(width: Int, height: Int): Long + private external fun createDrawingEngine(width: Int, height: Int, continuous: Boolean): Long + private external fun resizeContinuousDrawingEngine(engine: Long, width: Int, height: Int): Boolean private external fun destroyDrawingEngine(engine: Long) private external fun setRenderViewport(engine: Long, renderScale: Float, visibleLeft: Float, visibleTop: Float, visibleWidth: Float, visibleHeight: Float) @@ -1360,9 +1429,11 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. private external fun updateHoldShapePreview(engine: Long, timestamp: Long): Boolean // Canvas operations + private external fun clearDocumentPageNative(engine: Long, pageIndex: Int, pageHeight: Float): Boolean private external fun clearCanvas(engine: Long) - private external fun undoStroke(engine: Long) - private external fun redoStroke(engine: Long) + private external fun recordExternalEditNative(engine: Long, before: String, after: String): Boolean + private external fun undoStroke(engine: Long): String? + private external fun redoStroke(engine: Long): String? // Tool settings private external fun setStrokeColor(engine: Long, color: Int) diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt index 7d92b1c..4505375 100644 --- a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt @@ -65,6 +65,11 @@ class MobileInkCanvasViewManager(private val reactContext: ReactApplicationConte view.renderBackend = backend ?: "ganesh" } + @ReactProp(name = "continuousDocument") + fun setContinuousDocument(view: MobileInkCanvasView, enabled: Boolean) { + view.continuousDocument = enabled + } + @ReactProp(name = "renderScale") fun setRenderScale(view: MobileInkCanvasView, scale: Float) { view.renderScale = scale @@ -112,6 +117,9 @@ class MobileInkCanvasViewManager(private val reactContext: ReactApplicationConte ) { when (commandId) { "clear" -> root.clear() + "clearDocumentPage" -> { + if (args != null && args.size() == 2) root.clearDocumentPage(args.getInt(0), args.getDouble(1).toFloat()) + } "undo" -> root.undo() "redo" -> root.redo() "setTool" -> { diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkDocumentBridge.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkDocumentBridge.kt new file mode 100644 index 0000000..3592ea6 --- /dev/null +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkDocumentBridge.kt @@ -0,0 +1,155 @@ +package com.mathnotes.mobileink + +import android.util.Base64 +import com.facebook.react.bridge.* +import com.facebook.react.module.annotations.ReactModule +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.Executors + +/** Whole-stroke document codec; no view or GL context is needed. */ +@ReactModule(name = MobileInkDocumentBridge.NAME) +class MobileInkDocumentBridge(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { + companion object { const val NAME = "MobileInkDocumentBridge" } + private val executor = Executors.newSingleThreadExecutor() + private data class Page(val bytes: ByteArray, val order: LongArray, val hasOrder: Boolean) + + override fun getName() = NAME + override fun invalidate() { + // Complete accepted codec requests before releasing the worker. + executor.shutdown() + super.invalidate() + } + + private fun decode(payload: String): Page { + val root = JSONObject(payload) + val pages = root.getJSONObject("pages") + require(pages.length() == 0 || (pages.length() == 1 && pages.has("0"))) { + "Expected a single native page payload" + } + val bytes = if (pages.length() == 0) byteArrayOf() else { + val encoded = pages.get("0") + require(encoded is String && encoded.isNotEmpty()) { "Invalid drawing encoding" } + val decoded = Base64.decode(encoded, Base64.NO_WRAP) + require(decoded.isNotEmpty() && Base64.encodeToString(decoded, Base64.NO_WRAP) == encoded) { + "Invalid drawing bytes" + } + decoded + } + val hasOrder = root.has("documentStrokeOrder") + val order = if (hasOrder) root.getJSONArray("documentStrokeOrder") else JSONArray() + val values = LongArray(order.length()) { index -> + val value = order.get(index) + require(value is Number) { "Invalid document stroke order value" } + val number = value.toDouble() + require(number.isFinite() && number >= 0 && number <= 9007199254740991.0 && + number == kotlin.math.floor(number)) { "Invalid document stroke order range" } + number.toLong() + } + return Page(bytes, values, hasOrder) + } + + private fun encode(page: DocumentPageData, includeOrder: Boolean): String { + val pages = JSONObject() + if (page.bytes.isNotEmpty()) pages.put("0", Base64.encodeToString(page.bytes, Base64.NO_WRAP)) + val root = JSONObject().put("pages", pages) + if (includeOrder) root.put("documentStrokeOrder", JSONArray().apply { + page.order.forEach { put(it) } + }) + return root.toString() + } + + private fun payloads(values: ReadableArray): List = (0 until values.size()).map { + require(values.getType(it) == ReadableType.String) { "Invalid page payload type" } + requireNotNull(values.getString(it)) + } + + private fun run(promise: Promise, code: String, operation: () -> Any) { + try { + executor.execute { + try { promise.resolve(operation()) } + catch (error: Exception) { promise.reject(code, error) } + catch (error: LinkageError) { promise.reject(code, error) } + } + } catch (error: Exception) { promise.reject(code, error) } + } + + @ReactMethod + fun composeDocument(values: ReadableArray, pageHeight: Double, promise: Promise) { + // Copy React bridge values before moving to the worker. + val payloads = try { payloads(values) } catch (error: Exception) { + promise.reject("DOCUMENT_COMPOSE_FAILED", error); return + } + run(promise, "DOCUMENT_COMPOSE_FAILED") { + val pages = payloads.map(::decode) + val bytes = DocumentCodec.compose(pages.map { it.bytes }.toTypedArray(), + pages.map { it.order }.toTypedArray(), pages.map { it.hasOrder }.toBooleanArray(), pageHeight.toFloat()) + encode(DocumentPageData(bytes, longArrayOf()), false) + } + } + + @ReactMethod + fun loadDocument(viewTag: Int, values: ReadableArray, pageHeight: Double, promise: Promise) { + val payloads = try { payloads(values) } catch (error: Exception) { + promise.reject("DOCUMENT_LOAD_FAILED", error); return + } + try { + executor.execute { + try { + val pages = payloads.map(::decode) + val bytes = DocumentCodec.compose(pages.map { it.bytes }.toTypedArray(), + pages.map { it.order }.toTypedArray(), pages.map { it.hasOrder }.toBooleanArray(), pageHeight.toFloat()) + val payload = encode(DocumentPageData(bytes, longArrayOf()), false) + val loader = reactApplicationContext.getNativeModule(MobileInkModule::class.java) + ?: throw IllegalStateException("MobileInkModule is unavailable") + // This resolves only after the GL thread has loaded the drawing. + loader.loadBase64Data(viewTag, payload, promise) + } catch (error: Exception) { promise.reject("DOCUMENT_LOAD_FAILED", error) } + catch (error: LinkageError) { promise.reject("DOCUMENT_LOAD_FAILED", error) } + } + } catch (error: Exception) { promise.reject("DOCUMENT_LOAD_FAILED", error) } + } + + @ReactMethod + fun partitionDocument(payload: String, pageCount: Double, pageHeight: Double, promise: Promise) { + run(promise, "DOCUMENT_PARTITION_FAILED") { + require(pageCount.isFinite() && pageCount >= 1 && pageCount <= 100000 && + pageCount == kotlin.math.floor(pageCount)) { "Invalid document page count" } + val page = decode(payload) + val pages = DocumentCodec.partition(page.bytes, page.order, page.hasOrder, pageCount.toInt(), pageHeight.toFloat()) + Arguments.createArray().apply { pages.forEach { pushString(encode(it, true)) } } + } + } + + @ReactMethod + fun projectDocumentPages(values: ReadableArray, pageHeight: Double, requested: ReadableArray, promise: Promise) { + val payloads: List + val indices: IntArray + try { + payloads = payloads(values) + indices = IntArray(requested.size()) { + require(requested.getType(it) == ReadableType.Number) { "Invalid export page index" } + val index = requested.getDouble(it) + require(index.isFinite() && index >= 0 && index < payloads.size && + index == kotlin.math.floor(index)) { "Invalid export page index" } + index.toInt() + } + } catch (error: Exception) { promise.reject("DOCUMENT_EXPORT_FAILED", error); return } + run(promise, "DOCUMENT_EXPORT_FAILED") { + val pages = payloads.map(::decode) + val projected = DocumentCodec.project(pages.map { it.bytes }.toTypedArray(), + pages.map { it.order }.toTypedArray(), pages.map { it.hasOrder }.toBooleanArray(), pageHeight.toFloat(), indices) + Arguments.createArray().apply { projected.forEach { pushString(encode(it, false)) } } + } + } +} + +// Kept as a simple Java-visible constructor for JNI. Consumer rules preserve it. +class DocumentPageData(val bytes: ByteArray, val order: LongArray) + +internal object DocumentCodec { + init { System.loadLibrary("mobileink") } + external fun compose(bytes: Array, orders: Array, hasOrders: BooleanArray, height: Float): ByteArray + external fun partition(bytes: ByteArray, order: LongArray, hasOrder: Boolean, count: Int, height: Float): Array + external fun project(bytes: Array, orders: Array, hasOrders: BooleanArray, height: Float, indices: IntArray): Array +} diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt index f6ec879..232c9b8 100644 --- a/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkModule.kt @@ -100,6 +100,17 @@ class MobileInkModule(private val reactContext: ReactApplicationContext) : return File(filePath) } + @ReactMethod + fun recordExternalEdit(viewTag: Int, before: String, after: String, promise: Promise) { + reactContext.runOnUiQueueThread { + try { + promise.resolve(findDrawingView(viewTag)?.recordExternalEdit(before, after) ?: false) + } catch (error: Exception) { + promise.reject("EXTERNAL_HISTORY", "Could not record overlay history", error) + } + } + } + @ReactMethod fun getBase64Data(viewTag: Int, promise: Promise) { reactContext.runOnUiQueueThread { @@ -498,6 +509,17 @@ class MobileInkModule(private val reactContext: ReactApplicationContext) : } } + @ReactMethod + fun parseNotebookData(data: String, promise: Promise) { + executor.execute { + try { + promise.resolve(jsonObjectToWritableMap(JSONObject(data))) + } catch (e: Exception) { + promise.reject("PARSE_FAILED", e.message ?: "Notebook parse failed", e) + } + } + } + /** * Native body-file read + parse. Mirrors the iOS helper so Android callers * can skip a multi-MB JS string read/JSON.parse when opening large notebooks. diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt index c5aae1e..3f1ffa5 100644 --- a/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkPackage.kt @@ -15,16 +15,17 @@ import com.facebook.react.uimanager.ViewManager * with new architecture enabled. It provides module metadata via getReactModuleInfoProvider() * which allows RN to register modules without loading native libraries during startup scan. */ -@ReactModuleList(nativeModules = [MobileInkModule::class]) +@ReactModuleList(nativeModules = [MobileInkModule::class, MobileInkDocumentBridge::class]) class MobileInkPackage : TurboReactPackage() { override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(MobileInkModule(reactContext)) + return listOf(MobileInkModule(reactContext), MobileInkDocumentBridge(reactContext)) } override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { return when (name) { MobileInkModule.NAME -> MobileInkModule(reactContext) + MobileInkDocumentBridge.NAME -> MobileInkDocumentBridge(reactContext) else -> null } } @@ -32,6 +33,11 @@ class MobileInkPackage : TurboReactPackage() { override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { return ReactModuleInfoProvider { mapOf( + MobileInkDocumentBridge.NAME to ReactModuleInfo( + MobileInkDocumentBridge.NAME, + MobileInkDocumentBridge::class.java.name, + false, false, false, false, false + ), MobileInkModule.NAME to ReactModuleInfo( MobileInkModule.NAME, MobileInkModule::class.java.name, diff --git a/cpp/ActiveStrokeRenderer.cpp b/cpp/ActiveStrokeRenderer.cpp index 806c094..a6e5b9f 100644 --- a/cpp/ActiveStrokeRenderer.cpp +++ b/cpp/ActiveStrokeRenderer.cpp @@ -6,19 +6,13 @@ namespace nativedrawing { -ActiveStrokeRenderer::ActiveStrokeRenderer(int width, int height, PathRenderer* pathRenderer) +ActiveStrokeRenderer::ActiveStrokeRenderer(int width, int height, PathRenderer* pathRenderer, bool viewportOnly) : pathRenderer_(pathRenderer) + , viewportOnly_(viewportOnly) , logicalWidth_(width) , logicalHeight_(height) , lastRenderedInputIndex_(0) - , hasLastEdge_(false) { - - SkImageInfo info = SkImageInfo::MakeN32Premul(width, height); - activeStrokeSurface_ = SkSurfaces::Raster(info); - if (activeStrokeSurface_) { - activeStrokeSurface_->getCanvas()->clear(SK_ColorTRANSPARENT); - } -} + , hasLastEdge_(false) {} void ActiveStrokeRenderer::resetIncrementalState() { cachedActiveSnapshot_ = nullptr; @@ -30,10 +24,12 @@ void ActiveStrokeRenderer::resetIncrementalState() { } void ActiveStrokeRenderer::reset() { + // Release the snapshot before clearing its backing surface. Otherwise + // Skia copies the full page to preserve an image we're about to discard. + resetIncrementalState(); if (activeStrokeSurface_) { activeStrokeSurface_->getCanvas()->clear(SK_ColorTRANSPARENT); } - resetIncrementalState(); } void ActiveStrokeRenderer::ensureViewportAlignment(const ActiveStrokeViewport& viewport) { @@ -52,17 +48,25 @@ void ActiveStrokeRenderer::ensureViewportAlignment(const ActiveStrokeViewport& v const float viewHeight = viewport.height > 0.0f ? viewport.height : static_cast(logicalHeight_); + // Continuous documents keep a screen-sized preview even at identity zoom. + // A 4096px cap bounds the cache to 64 MiB independently of page count. + const int nextWidth = viewportOnly_ + ? std::min(4096, std::max(1, static_cast(std::ceil(viewWidth * requestedScale)))) + : logicalWidth_; + const int nextHeight = viewportOnly_ + ? std::min(4096, std::max(1, static_cast(std::ceil(viewHeight * requestedScale)))) + : logicalHeight_; const float fitScale = std::min( - static_cast(logicalWidth_) / std::max(1.0f, viewWidth), - static_cast(logicalHeight_) / std::max(1.0f, viewHeight) + static_cast(nextWidth) / std::max(1.0f, viewWidth), + static_cast(nextHeight) / std::max(1.0f, viewHeight) ); const float nextScale = std::max(kMinimumRenderScale, std::min(requestedScale, fitScale)); float nextOriginX = 0.0f; float nextOriginY = 0.0f; - if (nextScale > kIdentityRenderScaleThreshold) { - const float coveredWidth = static_cast(logicalWidth_) / nextScale; - const float coveredHeight = static_cast(logicalHeight_) / nextScale; + if (viewportOnly_ || nextScale > kIdentityRenderScaleThreshold) { + const float coveredWidth = std::min(static_cast(logicalWidth_), static_cast(nextWidth) / nextScale); + const float coveredHeight = std::min(static_cast(logicalHeight_), static_cast(nextHeight) / nextScale); nextOriginX = std::clamp( viewport.left, 0.0f, static_cast(logicalWidth_) - coveredWidth); nextOriginY = std::clamp( @@ -70,6 +74,7 @@ void ActiveStrokeRenderer::ensureViewportAlignment(const ActiveStrokeViewport& v } if (activeStrokeSurface_ + && nextWidth == surfaceWidth_ && nextHeight == surfaceHeight_ && std::fabs(nextScale - viewportScale_) < 0.01f && std::fabs(nextOriginX - viewportOriginX_) < 0.5f && std::fabs(nextOriginY - viewportOriginY_) < 0.5f) { @@ -80,16 +85,20 @@ void ActiveStrokeRenderer::ensureViewportAlignment(const ActiveStrokeViewport& v viewportOriginX_ = nextOriginX; viewportOriginY_ = nextOriginY; - if (!activeStrokeSurface_) { - SkImageInfo info = SkImageInfo::MakeN32Premul(logicalWidth_, logicalHeight_); + // Anchoring changed mid-stroke: discard incremental progress so the + // next render draws every point into the newly aligned surface. + resetIncrementalState(); + // Reading/scrolling a page does not need an active-stroke bitmap. Allocate + // on the first stroke and retain it for subsequent strokes on this engine. + if (!activeStrokeSurface_ || nextWidth != surfaceWidth_ || nextHeight != surfaceHeight_) { + surfaceWidth_ = nextWidth; + surfaceHeight_ = nextHeight; + SkImageInfo info = SkImageInfo::MakeN32Premul(surfaceWidth_, surfaceHeight_); activeStrokeSurface_ = SkSurfaces::Raster(info); } if (activeStrokeSurface_) { activeStrokeSurface_->getCanvas()->clear(SK_ColorTRANSPARENT); } - // Anchoring changed mid-stroke: drop incremental progress so the next - // renderIncremental call re-renders every point into the fresh surface. - resetIncrementalState(); } void ActiveStrokeRenderer::applySurfaceTransform(SkCanvas* surfaceCanvas) const { @@ -263,15 +272,15 @@ void ActiveStrokeRenderer::drawSnapshot(SkCanvas* canvas) const { } if (viewportScale_ <= kIdentityRenderScaleThreshold) { - canvas->drawImage(cachedActiveSnapshot_, 0, 0); + canvas->drawImage(cachedActiveSnapshot_, viewportOriginX_, viewportOriginY_); return; } const SkRect dst = SkRect::MakeXYWH( viewportOriginX_, viewportOriginY_, - static_cast(logicalWidth_) / viewportScale_, - static_cast(logicalHeight_) / viewportScale_ + static_cast(surfaceWidth_) / viewportScale_, + static_cast(surfaceHeight_) / viewportScale_ ); canvas->drawImageRect( cachedActiveSnapshot_, diff --git a/cpp/ActiveStrokeRenderer.h b/cpp/ActiveStrokeRenderer.h index 1ac665f..1fc1846 100644 --- a/cpp/ActiveStrokeRenderer.h +++ b/cpp/ActiveStrokeRenderer.h @@ -33,13 +33,14 @@ struct ActiveStrokeViewport { * Implements surface caching and incremental rendering to maintain 60-120fps * during stroke input, regardless of stroke complexity. * - * The surface is always allocated at the engine's logical dimensions. When - * the viewport is zoomed, the surface covers only the visible region at - * magnification instead of the whole canvas, keeping memory constant. + * Page engines allocate their preview at logical dimensions on first use. + * Viewport-only document engines allocate the visible pixel dimensions (capped + * at 4096 per axis), anchored in document coordinates even at identity zoom. + * Moving the viewport rebuilds incremental ink in the newly aligned surface. */ class ActiveStrokeRenderer { public: - explicit ActiveStrokeRenderer(int width, int height, PathRenderer* pathRenderer); + explicit ActiveStrokeRenderer(int width, int height, PathRenderer* pathRenderer, bool viewportOnly = false); /** * Reset state for a new stroke @@ -80,7 +81,7 @@ class ActiveStrokeRenderer { /** * Effective magnification the preview surface is currently anchored at. - * 1.0 means the surface maps 1:1 onto the full canvas. + * 1.0 means preview pixels map 1:1 onto document coordinates. */ float viewportScale() const { return viewportScale_; } @@ -91,6 +92,9 @@ class ActiveStrokeRenderer { private: PathRenderer* pathRenderer_; + const bool viewportOnly_; + int surfaceWidth_ = 0; + int surfaceHeight_ = 0; int logicalWidth_; int logicalHeight_; float viewportScale_ = 1.0f; diff --git a/cpp/ContinuousDocumentPages.cpp b/cpp/ContinuousDocumentPages.cpp new file mode 100644 index 0000000..20bb35d --- /dev/null +++ b/cpp/ContinuousDocumentPages.cpp @@ -0,0 +1,145 @@ +#include "ContinuousDocumentPages.h" +#include +#include +#include +#include +#include +#include +#include + +namespace nativedrawing { +namespace { +void validateHeight(float height) { + if (!std::isfinite(height) || height <= 0) throw std::invalid_argument("Invalid document page height"); +} +void translate(Stroke& stroke, float offset) { + if (!std::isfinite(offset)) throw std::invalid_argument("Invalid page offset"); + for (auto& point : stroke.points) { + if (!std::isfinite(point.x) || !std::isfinite(point.y + offset)) + throw std::invalid_argument("Invalid stroke coordinate"); + point.y += offset; + } + for (auto& circle : stroke.erasedBy) { + if (!std::isfinite(circle.x) || !std::isfinite(circle.y + offset) || + !std::isfinite(circle.radius) || circle.radius < 0) + throw std::invalid_argument("Invalid eraser coordinate"); + circle.y += offset; + } + // Keep decoded geometry in the same coordinate space as its points. + // Export uses these bounds before serializing page-local render payloads. + stroke.path.transform(SkMatrix::Translate(0, offset)); + stroke.cachedEraserPath.reset(); + stroke.cachedEraserCount = 0; + stroke.cachedHasVisiblePoints = true; +} +} + +std::vector partitionContinuousDocument( + const std::vector& strokes, int pageCount, float pageHeight) { + validateHeight(pageHeight); + if (pageCount <= 0) throw std::invalid_argument("Invalid document page count"); + std::vector pages(static_cast(pageCount)); + for (size_t index = 0; index < strokes.size(); ++index) { + const auto& stroke = strokes[index]; + if (stroke.points.empty()) throw std::invalid_argument("Cannot partition a stroke without points"); + const float y = stroke.points.front().y; + if (!std::isfinite(y)) throw std::invalid_argument("Invalid stroke position"); + const double owner = std::clamp(std::floor(static_cast(y) / pageHeight), 0.0, double(pageCount - 1)); + auto& page = pages[static_cast(owner)]; + page.strokes.push_back(stroke); + translate(page.strokes.back(), -static_cast(owner) * pageHeight); + page.order.push_back(index); + } + return pages; +} + +std::vector composeContinuousDocument( + const std::vector& pages, float pageHeight) { + validateHeight(pageHeight); + struct Entry { Stroke stroke; uint64_t order; }; + std::vector entries; + std::unordered_set usedOrders; + uint64_t nextOrder = 0; + for (const auto& page : pages) { + if (!page.order.empty() && page.order.size() != page.strokes.size()) + throw std::invalid_argument("Stroke order does not match page data"); + for (uint64_t order : page.order) { + if (order == std::numeric_limits::max() || !usedOrders.insert(order).second) + throw std::invalid_argument("Invalid or duplicate document stroke order"); + nextOrder = std::max(nextOrder, order + 1); + } + } + for (size_t pageIndex = 0; pageIndex < pages.size(); ++pageIndex) { + const auto& page = pages[pageIndex]; + const bool legacy = page.order.empty(); + if (legacy && page.strokes.size() > std::numeric_limits::max() - nextOrder) + throw std::overflow_error("Document stroke order overflow"); + const uint64_t legacyStart = nextOrder; + if (legacy) nextOrder += page.strokes.size(); + for (size_t index = 0; index < page.strokes.size(); ++index) { + Entry entry{page.strokes[index], legacy ? legacyStart + index : page.order[index]}; + translate(entry.stroke, static_cast(pageIndex) * pageHeight); + if (legacy) { + std::unordered_set targets; + for (size_t target : entry.stroke.affectedStrokeIndices) { + if (target < page.strokes.size()) targets.insert(static_cast(legacyStart + target)); + } + entry.stroke.affectedStrokeIndices = std::move(targets); + } + entries.push_back(std::move(entry)); + } + } + std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) { return a.order < b.order; }); + std::unordered_map indices; + for (size_t index = 0; index < entries.size(); ++index) indices.emplace(entries[index].order, index); + std::vector strokes; + strokes.reserve(entries.size()); + for (auto& entry : entries) { + std::unordered_set targets; + for (size_t target : entry.stroke.affectedStrokeIndices) { + const auto mapped = indices.find(target); + if (mapped != indices.end()) targets.insert(mapped->second); + } + entry.stroke.affectedStrokeIndices = std::move(targets); + strokes.push_back(std::move(entry.stroke)); + } + return strokes; +} +std::vector> projectContinuousDocumentPages( + const std::vector& pages, float pageHeight, + const std::vector& pageIndices) { + for (int index : pageIndices) { + if (index < 0 || static_cast(index) >= pages.size()) + throw std::invalid_argument("Invalid export page index"); + } + const auto document = composeContinuousDocument(pages, pageHeight); + std::vector> result; + result.reserve(pageIndices.size()); + for (int pageIndex : pageIndices) { + const float top = pageIndex * pageHeight; + std::vector projected; + std::unordered_map indices; + for (size_t index = 0; index < document.size(); ++index) { + const auto& stroke = document[index]; + SkRect bounds = stroke.path.getBounds(); + float margin = std::max(8.0f, stroke.paint.getStrokeWidth() * 2); + for (const auto& point : stroke.points) margin = std::max(margin, point.calculatedWidth * 2); + if (bounds.bottom() + margin < top || bounds.top() - margin > top + pageHeight) continue; + indices.emplace(index, projected.size()); + projected.push_back(stroke); + translate(projected.back(), -top); + } + for (auto& stroke : projected) { + std::unordered_set targets; + for (size_t target : stroke.affectedStrokeIndices) { + const auto found = indices.find(target); + if (found != indices.end()) targets.insert(found->second); + } + stroke.affectedStrokeIndices = std::move(targets); + } + result.push_back(std::move(projected)); + } + return result; +} + +} diff --git a/cpp/ContinuousDocumentPages.h b/cpp/ContinuousDocumentPages.h new file mode 100644 index 0000000..ac36ff7 --- /dev/null +++ b/cpp/ContinuousDocumentPages.h @@ -0,0 +1,23 @@ +#pragma once +#include "DrawingTypes.h" +#include +#include + +namespace nativedrawing { +// New document pages retain whole strokes in owner-page coordinates. The order +// array identifies original document indices (including legacy eraser targets). +// An empty order array on a nonempty page denotes the legacy page-local format. +struct ContinuousDocumentPage { + std::vector strokes; + std::vector order; +}; +std::vector partitionContinuousDocument( + const std::vector& strokes, int pageCount, float pageHeight); +std::vector composeContinuousDocument( + const std::vector& pages, float pageHeight); +// Render-only page payloads include neighboring overflow. Never persist these: +// crossing strokes deliberately occur in every page whose clip they intersect. +std::vector> projectContinuousDocumentPages( + const std::vector& pages, float pageHeight, + const std::vector& pageIndices); +} diff --git a/cpp/DrawingHistory.cpp b/cpp/DrawingHistory.cpp index 038c559..51ba1dd 100644 --- a/cpp/DrawingHistory.cpp +++ b/cpp/DrawingHistory.cpp @@ -25,6 +25,8 @@ void applyStrokeDelta( PathRenderer& pathRenderer ) { switch (delta.kind) { + case StrokeDelta::Kind::ExternalEdit: + break; case StrokeDelta::Kind::AddStrokes: { for (const auto& stroke : delta.addedStrokes) { strokes.push_back(stroke); @@ -90,6 +92,8 @@ void revertStrokeDelta( PathRenderer& pathRenderer ) { switch (delta.kind) { + case StrokeDelta::Kind::ExternalEdit: + break; case StrokeDelta::Kind::AddStrokes: { for (size_t i = 0; i < delta.addedStrokes.size(); ++i) { if (!strokes.empty()) { diff --git a/cpp/DrawingTypes.h b/cpp/DrawingTypes.h index 3fc9fcf..87f4b07 100644 --- a/cpp/DrawingTypes.h +++ b/cpp/DrawingTypes.h @@ -90,9 +90,12 @@ struct StrokeDelta { PixelErase, // erasedBy circles appended to one or more strokes (pixel eraser) MoveStrokes, // strokes translated by (dx, dy) (selection finalize-move) ReplaceStrokes, // selected strokes transformed in place (resize handles) + ExternalEdit, // opaque before/after overlay patch; no ink mutation Clear, // all strokes wiped (clear button, full-screen erase) }; Kind kind; + std::string externalBefore; + std::string externalAfter; // For AddStrokes: the strokes that were appended (1 entry for normal // pen stroke, N entries for paste). Undo = pop_back N, redo = push_back from delta. diff --git a/cpp/SkiaDrawingEngine.cpp b/cpp/SkiaDrawingEngine.cpp index 26d976e..7d2a5df 100644 --- a/cpp/SkiaDrawingEngine.cpp +++ b/cpp/SkiaDrawingEngine.cpp @@ -57,21 +57,20 @@ float limitWidthDelta(float previousWidth, float nextWidth, float baseWidth) { } // namespace -SkiaDrawingEngine::SkiaDrawingEngine(int width, int height) - : width_(width) +SkiaDrawingEngine::SkiaDrawingEngine(int width, int height, bool viewportOnly) + : viewportOnly_(viewportOnly) + , width_(width) , height_(height) , currentTool_("pen") , eraserMode_("pixel") , needsStrokeRedraw_(true) , visibleWidth_(static_cast(width)) , visibleHeight_(static_cast(height)) - , needsEraserMaskRedraw_(true) , hasLastSmoothedPoint_(false) , eraserCursorX_(0) , eraserCursorY_(0) , eraserCursorRadius_(0) , showEraserCursor_(false) - , cachedEraserCircleCount_(0) , bakedCircleCount_(0) , maxAffectedStrokeIndex_(0) , backgroundType_("plain") @@ -82,7 +81,7 @@ SkiaDrawingEngine::SkiaDrawingEngine(int width, int height) , eraserRenderer_(std::make_unique()) , strokeSplitter_(std::make_unique(pathRenderer_.get())) , batchExporter_(std::make_unique(width, height)) - , activeStrokeRenderer_(std::make_unique(width, height, pathRenderer_.get())) { + , activeStrokeRenderer_(std::make_unique(width, height, pathRenderer_.get(), viewportOnly)) { // Initialize paint with high-quality settings currentPaint_.setAntiAlias(true); @@ -95,19 +94,13 @@ SkiaDrawingEngine::SkiaDrawingEngine(int width, int height) // Create offscreen surface for strokes only (transparent background) SkImageInfo info = SkImageInfo::MakeN32Premul(width, height); - strokeSurface_ = SkSurfaces::Raster(info); + if (!viewportOnly_) strokeSurface_ = SkSurfaces::Raster(info); if (strokeSurface_) { strokeSurface_->getCanvas()->clear(SK_ColorTRANSPARENT); cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); // Initial empty snapshot } - // Create eraser mask surface (full RGBA for proper DstIn compositing) - eraserMaskSurface_ = SkSurfaces::Raster(info); - if (eraserMaskSurface_) { - eraserMaskSurface_->getCanvas()->clear(SK_ColorWHITE); - } - // History starts empty -- canUndo/canRedo are derived from the // stacks' emptiness so no sentinel is needed. } @@ -709,45 +702,58 @@ void SkiaDrawingEngine::clear() { eraserCursorY_ = 0.0f; markStrokeCachesDirty(); - needsEraserMaskRedraw_ = true; } -void SkiaDrawingEngine::undo() { +bool SkiaDrawingEngine::commitExternalEdit(const std::string& before, const std::string& after) { std::lock_guard lock(stateMutex_); + if (before.empty() || after.empty() || before == after || !currentPoints_.empty() || + isDraggingSelection_ || isTransformingSelection_) return false; + StrokeDelta delta; + delta.kind = StrokeDelta::Kind::ExternalEdit; + delta.externalBefore = before; + delta.externalAfter = after; + commitDelta(std::move(delta)); + return true; +} - if (undoStack_.empty()) return; +std::string SkiaDrawingEngine::undo() { + std::lock_guard lock(stateMutex_); + if (undoStack_.empty()) return {}; cancelSelectionTransform(); StrokeDelta delta = std::move(undoStack_.back()); undoStack_.pop_back(); + const bool external = delta.kind == StrokeDelta::Kind::ExternalEdit; + std::string patch = external ? delta.externalBefore : std::string(); revertDelta(delta); redoStack_.push_back(std::move(delta)); - resetIndexedSelectionState(); - - cachedEraserCircleCount_ = 0; - bakedCircleCount_ = 0; - clearActiveShapePreview(); - activeStrokeRenderer_->reset(); - markStrokeCachesDirty(); - needsEraserMaskRedraw_ = true; + if (!external) { + resetIndexedSelectionState(); + bakedCircleCount_ = 0; + clearActiveShapePreview(); + activeStrokeRenderer_->reset(); + markStrokeCachesDirty(); + } + return patch; } -void SkiaDrawingEngine::redo() { +std::string SkiaDrawingEngine::redo() { std::lock_guard lock(stateMutex_); - - if (redoStack_.empty()) return; + if (redoStack_.empty()) return {}; cancelSelectionTransform(); StrokeDelta delta = std::move(redoStack_.back()); redoStack_.pop_back(); + const bool external = delta.kind == StrokeDelta::Kind::ExternalEdit; + std::string patch = external ? delta.externalAfter : std::string(); applyDelta(delta); undoStack_.push_back(std::move(delta)); - resetIndexedSelectionState(); - - cachedEraserCircleCount_ = 0; - bakedCircleCount_ = 0; - clearActiveShapePreview(); - activeStrokeRenderer_->reset(); - markStrokeCachesDirty(); - needsEraserMaskRedraw_ = true; + if (!external) { + resetIndexedSelectionState(); + bakedCircleCount_ = 0; + clearActiveShapePreview(); + activeStrokeRenderer_->reset(); + markStrokeCachesDirty(); + } + return patch; } void SkiaDrawingEngine::setStrokeColor(SkColor color) { @@ -871,7 +877,6 @@ bool SkiaDrawingEngine::deserializeDrawing(const std::vector& data) { undoStack_.clear(); redoStack_.clear(); markStrokeCachesDirty(); - needsEraserMaskRedraw_ = true; return true; } diff --git a/cpp/SkiaDrawingEngine.h b/cpp/SkiaDrawingEngine.h index c9d235b..04091fb 100644 --- a/cpp/SkiaDrawingEngine.h +++ b/cpp/SkiaDrawingEngine.h @@ -36,8 +36,11 @@ class ActiveStrokeRenderer; class SkiaDrawingEngine { public: - SkiaDrawingEngine(int width, int height); + // viewportOnly is for continuous documents. Set a visible viewport before + // rendering; native views must provide a viewport-sized output target. + SkiaDrawingEngine(int width, int height, bool viewportOnly = false); ~SkiaDrawingEngine(); + bool resizeDocument(int width, int height); // Drawing operations (called from touch events) void touchBegan(float x, float y, float pressure, float azimuth = 0.0f, float altitude = 1.57f, long timestamp = 0, bool isPencilInput = false); @@ -51,8 +54,11 @@ class SkiaDrawingEngine { // Canvas operations void clear(); - void undo(); - void redo(); + bool clearDocumentPage(int pageIndex, float pageHeight); + // Returns an opaque overlay patch for external edits, or empty for ink/no-op. + std::string undo(); + std::string redo(); + bool commitExternalEdit(const std::string& before, const std::string& after); void eraseObjects(); // Tool settings @@ -87,6 +93,8 @@ class SkiaDrawingEngine { // Rendering void render(SkCanvas* canvas); + // Render the configured visible rectangle into a viewport-sized target. + void renderVisibleViewport(SkCanvas* canvas); void renderForExport(SkCanvas* canvas); void setRenderViewport( float renderScale, @@ -155,6 +163,8 @@ class SkiaDrawingEngine { SkPath pendingPixelEraserPath_; bool applyPendingPixelEraseToStrokes(); + // Continuous documents render bounded tiles instead of a document-sized bitmap. + const bool viewportOnly_; int width_; int height_; @@ -250,12 +260,6 @@ class SkiaDrawingEngine { float visibleWidth_; float visibleHeight_; - // Eraser mask surface for dual-surface architecture - // White = keep stroke, Black = erase stroke - // This allows eraser updates without re-rendering all strokes - sk_sp eraserMaskSurface_; - bool needsEraserMaskRedraw_; - // Eraser cursor state float eraserCursorX_; float eraserCursorY_; @@ -268,10 +272,6 @@ class SkiaDrawingEngine { float lastEraserRadius_ = 0; bool hasLastEraserPoint_ = false; - // OPTIMIZATION: Cached eraser path to avoid rebuilding every frame - SkPath cachedEraserPath_; - size_t cachedEraserCircleCount_; - // Track how many circles are already baked into strokeSurface_ // During render: only apply kClear for circles beyond this count (new circles) // During redraw: apply ALL circles, then update this count @@ -328,9 +328,9 @@ class SkiaDrawingEngine { const SkPath& cachedPath, const SkPaint& paint ); - sk_sp renderStrokeTile(const StrokeTileKey& key, int tileWidth, int tileHeight, float scale); + const void* strokeTileContext_ = nullptr; + sk_sp renderStrokeTile(SkCanvas* targetCanvas, const StrokeTileKey& key, int tileWidth, int tileHeight, float scale); void pruneStrokeTileCache(); - void redrawEraserMask(); // Dual-surface: only redraws eraser circles to mask // Pixel eraser stroke splitting - splits a stroke by removing portions that intersect the eraser std::vector splitStrokeAtPoint(const Stroke& originalStroke, const Point& eraserPoint, float eraserRadius); diff --git a/cpp/SkiaDrawingEngineDocument.cpp b/cpp/SkiaDrawingEngineDocument.cpp new file mode 100644 index 0000000..15707df --- /dev/null +++ b/cpp/SkiaDrawingEngineDocument.cpp @@ -0,0 +1,45 @@ +#include "SkiaDrawingEngine.h" +#include "ActiveStrokeRenderer.h" +#include "BatchExporter.h" +#include +#include + +namespace nativedrawing { + +bool SkiaDrawingEngine::resizeDocument(int width, int height) { + std::lock_guard lock(stateMutex_); + if (!viewportOnly_ || width <= 0 || height <= 0) return false; + if (width == width_ && height == height_) return true; + + // Geometry and history stay in document coordinates. Page insertion/removal + // owns any required translation; changing the extent must not erase edits. + width_ = width; + height_ = height; + activeStrokeRenderer_ = std::make_unique(width, height, pathRenderer_.get(), true); + batchExporter_ = std::make_unique(width, height); + setRenderViewport(renderScale_, visibleLeft_, visibleTop_, visibleWidth_, visibleHeight_); + markStrokeCachesDirty(); + return true; +} + +bool SkiaDrawingEngine::clearDocumentPage(int pageIndex, float pageHeight) { + std::lock_guard lock(stateMutex_); + if (!viewportOnly_ || pageIndex < 0 || !std::isfinite(pageHeight) || pageHeight <= 0 || + static_cast(pageIndex) * pageHeight >= height_) return false; + + cancelSelectionTransform(); + resetIndexedSelectionState(); + const double lastPage = std::ceil(static_cast(height_) / pageHeight) - 1; + for (size_t index = 0; index < strokes_.size(); ++index) { + const auto& points = strokes_[index].points; + if (points.empty()) continue; + // Match persisted ownership. A crossing stroke remains one object and + // one undo operation; clearing its owner removes the whole stroke. + const double owner = std::clamp(std::floor(static_cast(points.front().y) / pageHeight), 0.0, lastPage); + if (owner == pageIndex) selectedIndices_.insert(index); + } + deleteSelection(); + return true; +} + +} // namespace nativedrawing diff --git a/cpp/SkiaDrawingEngineEraser.cpp b/cpp/SkiaDrawingEngineEraser.cpp index 41a60fc..147a131 100644 --- a/cpp/SkiaDrawingEngineEraser.cpp +++ b/cpp/SkiaDrawingEngineEraser.cpp @@ -90,7 +90,7 @@ std::vector SkiaDrawingEngine::splitStrokeAtPoint( } void SkiaDrawingEngine::bakeEraserCircles() { - if (eraserCircles_.size() <= bakedCircleCount_) return; // Nothing new to bake + if (!strokeSurface_ || eraserCircles_.size() <= bakedCircleCount_) return; // Nothing new to bake // Apply kClear directly to strokeSurface_ using smooth stroke paths SkCanvas* canvas = strokeSurface_->getCanvas(); diff --git a/cpp/SkiaDrawingEngineRendering.cpp b/cpp/SkiaDrawingEngineRendering.cpp index c04a7b0..e1eac6a 100644 --- a/cpp/SkiaDrawingEngineRendering.cpp +++ b/cpp/SkiaDrawingEngineRendering.cpp @@ -19,8 +19,6 @@ namespace nativedrawing { namespace { -constexpr int kStrokeTileSize = 512; -constexpr size_t kMaxStrokeTileCacheBytes = 96 * 1024 * 1024; SkColor swapRedBlueChannels(SkColor color) { return SkColorSetARGB( @@ -41,90 +39,8 @@ void normalizeStrokeColorsForRasterExport(std::vector& strokes) { } } -int renderScaleBucket(float scale) { - const float clamped = std::max(kMinimumRenderScale, std::min(kMaximumRenderScale, scale)); - const float buckets[] = {1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 4.0f, 5.0f}; - float bestBucket = buckets[0]; - float bestDistance = std::fabs(clamped - bestBucket); - for (float bucket : buckets) { - const float distance = std::fabs(clamped - bucket); - if (distance < bestDistance) { - bestBucket = bucket; - bestDistance = distance; - } - } - return static_cast(std::round(bestBucket * 100.0f)); -} - } // namespace -void SkiaDrawingEngine::setRenderViewport( - float renderScale, - float visibleLeft, - float visibleTop, - float visibleWidth, - float visibleHeight -) { - std::lock_guard lock(stateMutex_); - - const float clampedScale = std::max(kMinimumRenderScale, std::min(kMaximumRenderScale, renderScale)); - renderScale_ = static_cast(renderScaleBucket(clampedScale)) / 100.0f; - visibleLeft_ = std::max(0.0f, std::min(static_cast(width_), visibleLeft)); - visibleTop_ = std::max(0.0f, std::min(static_cast(height_), visibleTop)); - visibleWidth_ = std::max(1.0f, std::min(static_cast(width_) - visibleLeft_, visibleWidth)); - visibleHeight_ = std::max(1.0f, std::min(static_cast(height_) - visibleTop_, visibleHeight)); -} - -void SkiaDrawingEngine::markStrokeCachesDirty() { - needsStrokeRedraw_ = true; - strokeTileEpoch_++; - if (strokeTileEpoch_ == 0) { - strokeTileEpoch_ = 1; - strokeTileCache_.clear(); - strokeTileCacheBytes_ = 0; - } -} - -void SkiaDrawingEngine::invalidateStrokeTilesForRect(const SkRect& bounds) { - if (strokeTileCache_.empty() || bounds.isEmpty()) { - return; - } - - const float expandedLeft = std::max(0.0f, bounds.left()); - const float expandedTop = std::max(0.0f, bounds.top()); - const float expandedRight = std::min(static_cast(width_), bounds.right()); - const float expandedBottom = std::min(static_cast(height_), bounds.bottom()); - if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) { - return; - } - - for (auto it = strokeTileCache_.begin(); it != strokeTileCache_.end();) { - const StrokeTileKey& key = it->first; - if (key.epoch != strokeTileEpoch_) { - strokeTileCacheBytes_ -= std::min(strokeTileCacheBytes_, it->second.bytes); - it = strokeTileCache_.erase(it); - continue; - } - - const float scale = key.scaleBucket / 100.0f; - const float tileLeft = static_cast(key.tileX * kStrokeTileSize) / scale; - const float tileTop = static_cast(key.tileY * kStrokeTileSize) / scale; - const float tileRight = static_cast((key.tileX + 1) * kStrokeTileSize) / scale; - const float tileBottom = static_cast((key.tileY + 1) * kStrokeTileSize) / scale; - const bool intersects = tileRight >= expandedLeft - && tileLeft <= expandedRight - && tileBottom >= expandedTop - && tileTop <= expandedBottom; - - if (intersects) { - strokeTileCacheBytes_ -= std::min(strokeTileCacheBytes_, it->second.bytes); - it = strokeTileCache_.erase(it); - } else { - ++it; - } - } -} - void SkiaDrawingEngine::setBackgroundType(const char* backgroundType) { std::lock_guard lock(stateMutex_); @@ -185,7 +101,7 @@ void SkiaDrawingEngine::renderStrokeGeometry(SkCanvas* canvas, const Stroke& str } void SkiaDrawingEngine::redrawStrokes() { - if (!needsStrokeRedraw_) return; + if (!needsStrokeRedraw_ || !strokeSurface_) return; SkCanvas* canvas = strokeSurface_->getCanvas(); canvas->clear(SK_ColorTRANSPARENT); @@ -239,193 +155,6 @@ void SkiaDrawingEngine::redrawStrokes() { needsStrokeRedraw_ = false; } -sk_sp SkiaDrawingEngine::renderStrokeTile( - const StrokeTileKey& key, - int tileWidth, - int tileHeight, - float scale -) { - SkImageInfo info = SkImageInfo::MakeN32Premul(tileWidth, tileHeight); - sk_sp tileSurface = SkSurfaces::Raster(info); - if (!tileSurface) { - return nullptr; - } - - SkCanvas* tileCanvas = tileSurface->getCanvas(); - tileCanvas->clear(SK_ColorTRANSPARENT); - tileCanvas->translate( - static_cast(-key.tileX * kStrokeTileSize), - static_cast(-key.tileY * kStrokeTileSize) - ); - tileCanvas->scale(scale, scale); - - const float tileLeft = static_cast(key.tileX * kStrokeTileSize) / scale; - const float tileTop = static_cast(key.tileY * kStrokeTileSize) / scale; - const float tileRight = static_cast(key.tileX * kStrokeTileSize + tileWidth) / scale; - const float tileBottom = static_cast(key.tileY * kStrokeTileSize + tileHeight) / scale; - SkRect tileBounds = SkRect::MakeLTRB(tileLeft, tileTop, tileRight, tileBottom); - - for (size_t i = 0; i < strokes_.size(); ++i) { - const auto& stroke = strokes_[i]; - if (stroke.isEraser) { - continue; - } - - SkRect strokeBounds = stroke.path.getBounds(); - const float outset = std::max(8.0f, stroke.paint.getStrokeWidth() * 2.0f); - strokeBounds.outset(outset, outset); - if (!strokeBounds.intersects(tileBounds)) { - continue; - } - - SkPaint strokePaint = stroke.paint; - uint8_t baseAlpha = stroke.paint.getAlpha(); - strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); - - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - tileCanvas->save(); - tileCanvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - - renderStrokeGeometry(tileCanvas, stroke, strokePaint); - - if (needsClipRestore) { - tileCanvas->restore(); - } - } - - return tileSurface->makeImageSnapshot(); -} - -void SkiaDrawingEngine::pruneStrokeTileCache() { - while (strokeTileCacheBytes_ > kMaxStrokeTileCacheBytes && !strokeTileCache_.empty()) { - auto oldest = strokeTileCache_.begin(); - for (auto it = strokeTileCache_.begin(); it != strokeTileCache_.end(); ++it) { - if (it->second.lastUsed < oldest->second.lastUsed) { - oldest = it; - } - } - strokeTileCacheBytes_ -= std::min(strokeTileCacheBytes_, oldest->second.bytes); - strokeTileCache_.erase(oldest); - } -} - -void SkiaDrawingEngine::renderScaleAwareStrokes(SkCanvas* canvas) { - const float scale = std::max(kMinimumRenderScale, renderScale_); - - if (!selectedIndices_.empty() && isDraggingSelection_) { - canvas->save(); - canvas->scale(scale, scale); - if (nonSelectedSnapshot_) { - canvas->drawImage(nonSelectedSnapshot_, 0, 0); - } else if (cachedStrokeSnapshot_) { - redrawStrokes(); - canvas->drawImage(cachedStrokeSnapshot_, 0, 0); - } - if (selectedSnapshot_) { - canvas->drawImage(selectedSnapshot_, selectionOffsetX_, selectionOffsetY_); - } - canvas->restore(); - return; - } - - if (!pendingDeleteIndices_.empty()) { - canvas->save(); - canvas->scale(scale, scale); - for (size_t i = 0; i < strokes_.size(); ++i) { - const auto& stroke = strokes_[i]; - SkPaint strokePaint = stroke.paint; - if (!stroke.isEraser) { - uint8_t baseAlpha = stroke.paint.getAlpha(); - const float alphaMod = pendingDeleteIndices_.count(i) > 0 - ? stroke.originalAlphaMod * 0.3f - : stroke.originalAlphaMod; - strokePaint.setAlpha(static_cast(baseAlpha * alphaMod)); - } - - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - canvas->save(); - canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - renderStrokeGeometry(canvas, stroke, strokePaint); - if (needsClipRestore) { - canvas->restore(); - } - } - canvas->restore(); - return; - } - - const int bucket = renderScaleBucket(scale); - const float bucketScale = bucket / 100.0f; - const float targetWidth = static_cast(width_) * bucketScale; - const float targetHeight = static_cast(height_) * bucketScale; - const float overscan = (static_cast(kStrokeTileSize) * 2.0f) / bucketScale; - const float visibleLeft = std::max(0.0f, visibleLeft_ - overscan); - const float visibleTop = std::max(0.0f, visibleTop_ - overscan); - const float visibleRight = std::min(static_cast(width_), visibleLeft_ + visibleWidth_ + overscan); - const float visibleBottom = std::min(static_cast(height_), visibleTop_ + visibleHeight_ + overscan); - - const int startTileX = std::max(0, static_cast(std::floor((visibleLeft * bucketScale) / kStrokeTileSize))); - const int startTileY = std::max(0, static_cast(std::floor((visibleTop * bucketScale) / kStrokeTileSize))); - const int endTileX = std::max(startTileX, static_cast(std::ceil((visibleRight * bucketScale) / kStrokeTileSize)) - 1); - const int endTileY = std::max(startTileY, static_cast(std::ceil((visibleBottom * bucketScale) / kStrokeTileSize)) - 1); - const int maxTileX = std::max(0, static_cast(std::ceil(targetWidth / kStrokeTileSize)) - 1); - const int maxTileY = std::max(0, static_cast(std::ceil(targetHeight / kStrokeTileSize)) - 1); - - for (int tileY = std::min(startTileY, maxTileY); tileY <= std::min(endTileY, maxTileY); ++tileY) { - for (int tileX = std::min(startTileX, maxTileX); tileX <= std::min(endTileX, maxTileX); ++tileX) { - StrokeTileKey key{bucket, tileX, tileY, strokeTileEpoch_}; - const int tilePixelLeft = tileX * kStrokeTileSize; - const int tilePixelTop = tileY * kStrokeTileSize; - const int tileWidth = std::max( - 1, - std::min(kStrokeTileSize, static_cast(std::ceil(targetWidth)) - tilePixelLeft) - ); - const int tileHeight = std::max( - 1, - std::min(kStrokeTileSize, static_cast(std::ceil(targetHeight)) - tilePixelTop) - ); - - auto it = strokeTileCache_.find(key); - if (it == strokeTileCache_.end()) { - sk_sp image = renderStrokeTile(key, tileWidth, tileHeight, bucketScale); - if (!image) { - continue; - } - StrokeTileEntry entry; - entry.image = std::move(image); - entry.bytes = static_cast(tileWidth) * static_cast(tileHeight) * 4; - entry.lastUsed = ++strokeTileUseCounter_; - strokeTileCacheBytes_ += entry.bytes; - auto inserted = strokeTileCache_.emplace(key, std::move(entry)); - it = inserted.first; - pruneStrokeTileCache(); - } else { - it->second.lastUsed = ++strokeTileUseCounter_; - } - - if (it != strokeTileCache_.end() && it->second.image) { - canvas->drawImage( - it->second.image, - static_cast(tilePixelLeft), - static_cast(tilePixelTop) - ); - } - } - } -} - void SkiaDrawingEngine::drawCenterlineStrokePath( SkCanvas* canvas, const std::vector& points, @@ -492,37 +221,10 @@ void SkiaDrawingEngine::renderActiveContent(SkCanvas* canvas) { } } -void SkiaDrawingEngine::redrawEraserMask() { - if (!needsEraserMaskRedraw_) return; - - SkCanvas* canvas = eraserMaskSurface_->getCanvas(); - canvas->clear(SK_ColorWHITE); // Full alpha (255) = visible - - if (!eraserCircles_.empty()) { - SkPaint erasePaint; - erasePaint.setBlendMode(SkBlendMode::kClear); // Sets pixels to 0 alpha (transparent = erased) - erasePaint.setAntiAlias(true); - erasePaint.setStyle(SkPaint::kFill_Style); - - // Build path from all circles (or use cached if available) - if (eraserCircles_.size() != cachedEraserCircleCount_) { - cachedEraserPath_.reset(); - for (const auto& circle : eraserCircles_) { - cachedEraserPath_.addCircle(circle.x, circle.y, circle.radius); - } - cachedEraserCircleCount_ = eraserCircles_.size(); - } - - canvas->drawPath(cachedEraserPath_, erasePaint); - } - - needsEraserMaskRedraw_ = false; -} - void SkiaDrawingEngine::render(SkCanvas* canvas) { std::lock_guard lock(stateMutex_); - const bool useScaleAwarePath = renderScale_ > kIdentityRenderScaleThreshold; + const bool useScaleAwarePath = viewportOnly_ || renderScale_ > kIdentityRenderScaleThreshold; if (useScaleAwarePath) { if (backgroundType_ == "pdf") { @@ -586,6 +288,9 @@ void SkiaDrawingEngine::render(SkCanvas* canvas) { if (isDraggingSelection_ && selectionHighlightSnapshot_) { canvas->drawImage(selectionHighlightSnapshot_, selectionOffsetX_, selectionOffsetY_); } else { + if (viewportOnly_ && isDraggingSelection_) { + canvas->translate(selectionOffsetX_, selectionOffsetY_); + } selection_->renderSelection(canvas, strokes_, selectedIndices_); } canvas->restore(); @@ -771,6 +476,7 @@ sk_sp SkiaDrawingEngine::makeSnapshot() { SkImageInfo info = SkImageInfo::MakeN32Premul(width_, height_); sk_sp surface = SkSurfaces::Raster(info); + if (!surface) return nullptr; const float originalRenderScale = renderScale_; const float originalVisibleLeft = visibleLeft_; const float originalVisibleTop = visibleTop_; @@ -857,7 +563,6 @@ std::vector SkiaDrawingEngine::batchExportPages( normalizeStrokeColorsForRasterExport(strokes_); markStrokeCachesDirty(); - needsEraserMaskRedraw_ = true; render(canvas); canvas->restore(); @@ -878,7 +583,6 @@ std::vector SkiaDrawingEngine::batchExportPages( // Restore original state strokes_ = originalStrokes; eraserCircles_ = originalEraserCircles; - cachedEraserCircleCount_ = 0; pdfBackgroundImage_ = originalPdfBackground; backgroundType_ = originalBackgroundType; backgroundOriginY_ = originalBackgroundOriginY; @@ -890,7 +594,6 @@ std::vector SkiaDrawingEngine::batchExportPages( visibleWidth_ = originalVisibleWidth; visibleHeight_ = originalVisibleHeight; markStrokeCachesDirty(); - needsEraserMaskRedraw_ = true; return results; } diff --git a/cpp/SkiaDrawingEngineSelection.cpp b/cpp/SkiaDrawingEngineSelection.cpp index ed6928a..0a997f8 100644 --- a/cpp/SkiaDrawingEngineSelection.cpp +++ b/cpp/SkiaDrawingEngineSelection.cpp @@ -351,7 +351,7 @@ void SkiaDrawingEngine::cancelSelectionTransform() { void SkiaDrawingEngine::prepareSelectionDragCache() { std::lock_guard lock(stateMutex_); - if (selectedIndices_.empty()) { + if (viewportOnly_ || selectedIndices_.empty()) { hasDragCache_ = false; return; } diff --git a/cpp/SkiaDrawingEngineZoomRendering.cpp b/cpp/SkiaDrawingEngineZoomRendering.cpp new file mode 100644 index 0000000..28a97e5 --- /dev/null +++ b/cpp/SkiaDrawingEngineZoomRendering.cpp @@ -0,0 +1,328 @@ +#include "SkiaDrawingEngine.h" + +#include +#include + +namespace nativedrawing { +namespace { +constexpr int kStrokeTileSize = 512; +constexpr size_t kMaxStrokeTileCacheBytes = 96 * 1024 * 1024; + +int renderScaleBucket(float scale) { + const float clamped = std::max(kMinimumRenderScale, std::min(kMaximumRenderScale, scale)); + const float buckets[] = {1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 4.0f, 5.0f}; + float bestBucket = buckets[0]; + float bestDistance = std::fabs(clamped - bestBucket); + for (float bucket : buckets) { + const float distance = std::fabs(clamped - bucket); + if (distance < bestDistance) { + bestBucket = bucket; + bestDistance = distance; + } + } + return static_cast(std::round(bestBucket * 100.0f)); +} + +} // namespace + +void SkiaDrawingEngine::setRenderViewport( + float renderScale, + float visibleLeft, + float visibleTop, + float visibleWidth, + float visibleHeight +) { + std::lock_guard lock(stateMutex_); + + const float clampedScale = std::max(kMinimumRenderScale, std::min(kMaximumRenderScale, renderScale)); + renderScale_ = static_cast(renderScaleBucket(clampedScale)) / 100.0f; + visibleLeft_ = std::max(0.0f, std::min(static_cast(width_), visibleLeft)); + visibleTop_ = std::max(0.0f, std::min(static_cast(height_), visibleTop)); + visibleWidth_ = std::max(1.0f, std::min(static_cast(width_) - visibleLeft_, visibleWidth)); + visibleHeight_ = std::max(1.0f, std::min(static_cast(height_) - visibleTop_, visibleHeight)); +} + +void SkiaDrawingEngine::renderVisibleViewport(SkCanvas* canvas) { + if (!canvas) return; + std::lock_guard lock(stateMutex_); + canvas->save(); + canvas->translate(-visibleLeft_ * renderScale_, -visibleTop_ * renderScale_); + render(canvas); + canvas->restore(); +} + +void SkiaDrawingEngine::markStrokeCachesDirty() { + needsStrokeRedraw_ = true; + strokeTileEpoch_++; + if (strokeTileEpoch_ == 0) { + strokeTileEpoch_ = 1; + strokeTileCache_.clear(); + strokeTileCacheBytes_ = 0; + } +} + +void SkiaDrawingEngine::invalidateStrokeTilesForRect(const SkRect& bounds) { + if (strokeTileCache_.empty() || bounds.isEmpty()) { + return; + } + + const float expandedLeft = std::max(0.0f, bounds.left()); + const float expandedTop = std::max(0.0f, bounds.top()); + const float expandedRight = std::min(static_cast(width_), bounds.right()); + const float expandedBottom = std::min(static_cast(height_), bounds.bottom()); + if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) { + return; + } + + for (auto it = strokeTileCache_.begin(); it != strokeTileCache_.end();) { + const StrokeTileKey& key = it->first; + if (key.epoch != strokeTileEpoch_) { + strokeTileCacheBytes_ -= std::min(strokeTileCacheBytes_, it->second.bytes); + it = strokeTileCache_.erase(it); + continue; + } + + const float scale = key.scaleBucket / 100.0f; + const float tileLeft = static_cast(key.tileX * kStrokeTileSize) / scale; + const float tileTop = static_cast(key.tileY * kStrokeTileSize) / scale; + const float tileRight = static_cast((key.tileX + 1) * kStrokeTileSize) / scale; + const float tileBottom = static_cast((key.tileY + 1) * kStrokeTileSize) / scale; + const bool intersects = tileRight >= expandedLeft + && tileLeft <= expandedRight + && tileBottom >= expandedTop + && tileTop <= expandedBottom; + + if (intersects) { + strokeTileCacheBytes_ -= std::min(strokeTileCacheBytes_, it->second.bytes); + it = strokeTileCache_.erase(it); + } else { + ++it; + } + } +} + +sk_sp SkiaDrawingEngine::renderStrokeTile( + SkCanvas* targetCanvas, + const StrokeTileKey& key, + int tileWidth, + int tileHeight, + float scale +) { + SkImageInfo info = SkImageInfo::MakeN32Premul(tileWidth, tileHeight); + sk_sp tileSurface = targetCanvas->makeSurface(info); + if (!tileSurface) { + tileSurface = SkSurfaces::Raster(info); + } + if (!tileSurface) { + return nullptr; + } + + SkCanvas* tileCanvas = tileSurface->getCanvas(); + tileCanvas->clear(SK_ColorTRANSPARENT); + tileCanvas->translate( + static_cast(-key.tileX * kStrokeTileSize), + static_cast(-key.tileY * kStrokeTileSize) + ); + tileCanvas->scale(scale, scale); + + const float tileLeft = static_cast(key.tileX * kStrokeTileSize) / scale; + const float tileTop = static_cast(key.tileY * kStrokeTileSize) / scale; + const float tileRight = static_cast(key.tileX * kStrokeTileSize + tileWidth) / scale; + const float tileBottom = static_cast(key.tileY * kStrokeTileSize + tileHeight) / scale; + SkRect tileBounds = SkRect::MakeLTRB(tileLeft, tileTop, tileRight, tileBottom); + + for (size_t i = 0; i < strokes_.size(); ++i) { + const auto& stroke = strokes_[i]; + if (stroke.isEraser) { + continue; + } + + SkRect strokeBounds = stroke.path.getBounds(); + const float outset = std::max(8.0f, stroke.paint.getStrokeWidth() * 2.0f); + strokeBounds.outset(outset, outset); + if (!strokeBounds.intersects(tileBounds)) { + continue; + } + + SkPaint strokePaint = stroke.paint; + uint8_t baseAlpha = stroke.paint.getAlpha(); + strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); + + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + tileCanvas->save(); + tileCanvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + + renderStrokeGeometry(tileCanvas, stroke, strokePaint); + + if (needsClipRestore) { + tileCanvas->restore(); + } + } + + return tileSurface->makeImageSnapshot(); +} + +void SkiaDrawingEngine::pruneStrokeTileCache() { + while (strokeTileCacheBytes_ > kMaxStrokeTileCacheBytes && !strokeTileCache_.empty()) { + auto oldest = strokeTileCache_.begin(); + for (auto it = strokeTileCache_.begin(); it != strokeTileCache_.end(); ++it) { + if (it->second.lastUsed < oldest->second.lastUsed) { + oldest = it; + } + } + strokeTileCacheBytes_ -= std::min(strokeTileCacheBytes_, oldest->second.bytes); + strokeTileCache_.erase(oldest); + } +} + +void SkiaDrawingEngine::renderScaleAwareStrokes(SkCanvas* canvas) { + const float scale = std::max(kMinimumRenderScale, renderScale_); + + if (!viewportOnly_ && !selectedIndices_.empty() && isDraggingSelection_) { + canvas->save(); + canvas->scale(scale, scale); + if (nonSelectedSnapshot_) { + canvas->drawImage(nonSelectedSnapshot_, 0, 0); + } else if (cachedStrokeSnapshot_) { + redrawStrokes(); + canvas->drawImage(cachedStrokeSnapshot_, 0, 0); + } + if (selectedSnapshot_) { + canvas->drawImage(selectedSnapshot_, selectionOffsetX_, selectionOffsetY_); + } + canvas->restore(); + return; + } + + if (!pendingDeleteIndices_.empty() || (viewportOnly_ && isDraggingSelection_)) { + canvas->save(); + canvas->scale(scale, scale); + SkRect clip; + const bool hasClip = canvas->getLocalClipBounds(&clip); + for (size_t i = 0; i < strokes_.size(); ++i) { + const auto& stroke = strokes_[i]; + const bool translated = isDraggingSelection_ && selectedIndices_.count(i) > 0; + SkRect bounds = stroke.path.getBounds(); + const float outset = std::max(8.0f, stroke.paint.getStrokeWidth() * 2.0f); + bounds.outset(outset, outset); + if (translated) bounds.offset(selectionOffsetX_, selectionOffsetY_); + if (hasClip && !bounds.intersects(clip)) continue; + SkPaint strokePaint = stroke.paint; + if (!stroke.isEraser) { + uint8_t baseAlpha = stroke.paint.getAlpha(); + const float alphaMod = pendingDeleteIndices_.count(i) > 0 + ? stroke.originalAlphaMod * 0.3f + : stroke.originalAlphaMod; + strokePaint.setAlpha(static_cast(baseAlpha * alphaMod)); + } + + canvas->save(); + if (translated) canvas->translate(selectionOffsetX_, selectionOffsetY_); + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + canvas->save(); + canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + renderStrokeGeometry(canvas, stroke, strokePaint); + if (needsClipRestore) { + canvas->restore(); + } + canvas->restore(); + } + canvas->restore(); + return; + } + + const void* context = canvas->recordingContext(); + if (strokeTileContext_ != context) { + strokeTileCache_.clear(); + strokeTileCacheBytes_ = 0; + strokeTileContext_ = context; + } + + const int bucket = renderScaleBucket(scale); + const float bucketScale = bucket / 100.0f; + const float targetWidth = static_cast(width_) * bucketScale; + const float targetHeight = static_cast(height_) * bucketScale; + const float overscan = (static_cast(kStrokeTileSize) * 2.0f) / bucketScale; + SkRect visibleBounds = SkRect::MakeLTRB( + std::max(0.0f, visibleLeft_ - overscan), + std::max(0.0f, visibleTop_ - overscan), + std::min(static_cast(width_), visibleLeft_ + visibleWidth_ + overscan), + std::min(static_cast(height_), visibleTop_ + visibleHeight_ + overscan) + ); + // A cropped target cannot display the overscan tiles. Building them here + // blocks the first sharp frame after zoom for work that will be discarded. + SkRect targetClip; + if (!canvas->getLocalClipBounds(&targetClip)) return; + targetClip = SkRect::MakeLTRB( + targetClip.left() / bucketScale, targetClip.top() / bucketScale, + targetClip.right() / bucketScale, targetClip.bottom() / bucketScale + ); + if (!visibleBounds.intersect(targetClip)) return; + const float visibleLeft = visibleBounds.left(); + const float visibleTop = visibleBounds.top(); + const float visibleRight = visibleBounds.right(); + const float visibleBottom = visibleBounds.bottom(); + + const int startTileX = std::max(0, static_cast(std::floor((visibleLeft * bucketScale) / kStrokeTileSize))); + const int startTileY = std::max(0, static_cast(std::floor((visibleTop * bucketScale) / kStrokeTileSize))); + const int endTileX = std::max(startTileX, static_cast(std::ceil((visibleRight * bucketScale) / kStrokeTileSize)) - 1); + const int endTileY = std::max(startTileY, static_cast(std::ceil((visibleBottom * bucketScale) / kStrokeTileSize)) - 1); + const int maxTileX = std::max(0, static_cast(std::ceil(targetWidth / kStrokeTileSize)) - 1); + const int maxTileY = std::max(0, static_cast(std::ceil(targetHeight / kStrokeTileSize)) - 1); + + for (int tileY = std::min(startTileY, maxTileY); tileY <= std::min(endTileY, maxTileY); ++tileY) { + for (int tileX = std::min(startTileX, maxTileX); tileX <= std::min(endTileX, maxTileX); ++tileX) { + StrokeTileKey key{bucket, tileX, tileY, strokeTileEpoch_}; + const int tilePixelLeft = tileX * kStrokeTileSize; + const int tilePixelTop = tileY * kStrokeTileSize; + const int tileWidth = std::max( + 1, + std::min(kStrokeTileSize, static_cast(std::ceil(targetWidth)) - tilePixelLeft) + ); + const int tileHeight = std::max( + 1, + std::min(kStrokeTileSize, static_cast(std::ceil(targetHeight)) - tilePixelTop) + ); + + auto it = strokeTileCache_.find(key); + if (it == strokeTileCache_.end()) { + sk_sp image = renderStrokeTile(canvas, key, tileWidth, tileHeight, bucketScale); + if (!image) { + continue; + } + StrokeTileEntry entry; + entry.image = std::move(image); + entry.bytes = static_cast(tileWidth) * static_cast(tileHeight) * 4; + entry.lastUsed = ++strokeTileUseCounter_; + strokeTileCacheBytes_ += entry.bytes; + auto inserted = strokeTileCache_.emplace(key, std::move(entry)); + it = inserted.first; + pruneStrokeTileCache(); + } else { + it->second.lastUsed = ++strokeTileUseCounter_; + } + + if (it != strokeTileCache_.end() && it->second.image) { + canvas->drawImage( + it->second.image, + static_cast(tilePixelLeft), + static_cast(tilePixelTop) + ); + } + } + } +} + +} // namespace nativedrawing diff --git a/ios/MobileInkModule/MobileInkBackgroundView.swift b/ios/MobileInkModule/MobileInkBackgroundView.swift index 86c4d6d..c4cd32b 100644 --- a/ios/MobileInkModule/MobileInkBackgroundView.swift +++ b/ios/MobileInkModule/MobileInkBackgroundView.swift @@ -83,11 +83,13 @@ class MobileInkBackgroundView: UIView { var backgroundType: String = "plain" { didSet { + guard oldValue != backgroundType else { return } setNeedsDisplay() } } var isDarkMode: Bool = false { didSet { + guard oldValue != isDarkMode else { return } setNeedsDisplay() } } diff --git a/ios/MobileInkModule/MobileInkBridge+NotebookParsing.swift b/ios/MobileInkModule/MobileInkBridge+NotebookParsing.swift new file mode 100644 index 0000000..9a05a87 --- /dev/null +++ b/ios/MobileInkModule/MobileInkBridge+NotebookParsing.swift @@ -0,0 +1,27 @@ +import Foundation +import React + +extension MobileInkBridge { + // Parse the exact payload selected by the caller's recovery logic. Reading + // a file by ID here could replace a newer cloud/cache candidate with stale ink. + @objc func parseNotebookData( + _ data: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + DispatchQueue.global(qos: .userInitiated).async { + autoreleasepool { + do { + let parsed = try JSONSerialization.jsonObject(with: Data(data.utf8)) + guard let notebook = parsed as? [String: Any] else { + rejecter("INVALID_NOTEBOOK", "Notebook root must be an object", nil) + return + } + resolver(notebook) + } catch { + rejecter("PARSE_FAILED", error.localizedDescription, error) + } + } + } + } +} diff --git a/ios/MobileInkModule/MobileInkBridge.m b/ios/MobileInkModule/MobileInkBridge.m index bdf1ea6..3c2566b 100644 --- a/ios/MobileInkModule/MobileInkBridge.m +++ b/ios/MobileInkModule/MobileInkBridge.m @@ -35,6 +35,10 @@ @interface RCT_EXTERN_MODULE(MobileInkBridge, RCTEventEmitter) resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(parseNotebookData:(NSString *)data + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + // Full-notebook native autosave: read existing body, replace ONLY the // visible window's per-page data with the engine's current state, write // atomically. Body bytes never cross the bridge. JS sends just visible diff --git a/ios/MobileInkModule/MobileInkBridge.mm b/ios/MobileInkModule/MobileInkBridge.mm index 752bf19..5c11889 100644 --- a/ios/MobileInkModule/MobileInkBridge.mm +++ b/ios/MobileInkModule/MobileInkBridge.mm @@ -83,7 +83,7 @@ bool deserializeDrawingBytes( return serializer.deserialize(buffer, strokes); } -void renderWithCpuColorOrdering(nativedrawing::SkiaDrawingEngine* engine, SkCanvas* canvas) { +void renderWithCpuColorOrdering(nativedrawing::SkiaDrawingEngine* engine, SkCanvas* canvas, bool cropped) { float redBlueSwapMatrix[20] = { 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, @@ -94,7 +94,8 @@ void renderWithCpuColorOrdering(nativedrawing::SkiaDrawingEngine* engine, SkCanv SkPaint outputPaint; outputPaint.setColorFilter(SkColorFilters::Matrix(redBlueSwapMatrix)); canvas->saveLayer(nullptr, &outputPaint); - engine->render(canvas); + if (cropped) engine->renderVisibleViewport(canvas); + else engine->render(canvas); canvas->restore(); } @@ -227,6 +228,14 @@ void translateStrokeInPlace( return new SkiaDrawingEngine(width, height); } +void* createContinuousDrawingEngine(int width, int height) { + return new SkiaDrawingEngine(width, height, true); +} + +bool resizeContinuousDrawingEngine(void* engine, int width, int height) { + return engine && static_cast(engine)->resizeDocument(width, height); +} + void destroyDrawingEngine(void* engine) { if (engine) { delete static_cast(engine); @@ -272,12 +281,33 @@ void addPredictedPoint(void* engine, float x, float y, float pressure, float azi } } +bool clearContinuousDocumentPage(void* engine, int pageIndex, float pageHeight) { + return engine && static_cast(engine)->clearDocumentPage(pageIndex, pageHeight); +} + void clearCanvas(void* engine) { if (engine) { static_cast(engine)->clear(); } } +bool commitExternalEdit(void* engine, const char* before, const char* after) { + return engine && before && after && + static_cast(engine)->commitExternalEdit(before, after); +} + +char* undoExternalEdit(void* engine) { + if (!engine) return nullptr; + const auto payload = static_cast(engine)->undo(); + return payload.empty() ? nullptr : strdup(payload.c_str()); +} + +char* redoExternalEdit(void* engine) { + if (!engine) return nullptr; + const auto payload = static_cast(engine)->redo(); + return payload.empty() ? nullptr : strdup(payload.c_str()); +} + void undoStroke(void* engine) { if (engine) { static_cast(engine)->undo(); @@ -353,6 +383,14 @@ void renderToCanvas(void* engine, void* canvas) { } } +void renderVisibleViewportToCanvas(void* engine, void* canvas) { + if (engine && canvas) static_cast(engine)->renderVisibleViewport(static_cast(canvas)); +} + +void renderToCanvasForExport(void* engine, void* canvas) { + if (engine && canvas) static_cast(engine)->renderForExport(static_cast(canvas)); +} + void setRenderViewport( void* engine, float renderScale, @@ -405,7 +443,7 @@ void destroyGaneshMetalContext(void* contextPtr) { delete contextHandle; } -bool renderToGaneshMetalTexture(void* engine, void* contextPtr, void* texturePtr, int width, int height) { +bool renderToGaneshMetalTexture(void* engine, void* contextPtr, void* texturePtr, int width, int height, bool cropped) { if (!engine || !contextPtr || !texturePtr || width <= 0 || height <= 0) { return false; } @@ -439,7 +477,7 @@ bool renderToGaneshMetalTexture(void* engine, void* contextPtr, void* texturePtr SkCanvas* canvas = surface->getCanvas(); canvas->clear(SK_ColorTRANSPARENT); - renderWithCpuColorOrdering(static_cast(engine), canvas); + renderWithCpuColorOrdering(static_cast(engine), canvas, cropped); directContext->flushAndSubmit(surface.get()); return true; } diff --git a/ios/MobileInkModule/MobileInkCanvasView.swift b/ios/MobileInkModule/MobileInkCanvasView.swift index a97737e..50f7a2c 100644 --- a/ios/MobileInkModule/MobileInkCanvasView.swift +++ b/ios/MobileInkModule/MobileInkCanvasView.swift @@ -76,7 +76,52 @@ class MobileInkCanvasView: MTKView { private var enginePixelHeight: Int32 = 0 private var isApplyingDrawableSize = false private var clampedRenderScale: CGFloat { - max(1.0, min(5.0, renderScale.isFinite ? renderScale : 1.0)) + let requested = max(1.0, min(5.0, renderScale.isFinite ? renderScale : 1.0)) + return [CGFloat(1), 1.5, 2, 2.5, 3, 4, 5].min { abs($0 - requested) < abs($1 - requested) } ?? 1 + } + + private var documentSize: CGSize = .zero + private var renderGeometryUpdatePending = false + private var usesCroppedViewport: Bool { continuousDocument || clampedRenderScale > 1.01 } + + func updateDocumentLayout(_ size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + documentSize = size + scheduleRenderGeometryUpdate() + } + + private func scheduleRenderGeometryUpdate() { + guard !renderGeometryUpdatePending else { return } + renderGeometryUpdatePending = true + // React delivers scale and viewport props separately. Apply their final + // values together, avoiding a transient full-page high-resolution allocation. + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.renderGeometryUpdatePending = false + guard !self.isReleased, self.documentSize.width > 0, self.documentSize.height > 0 else { return } + let full = CGRect(origin: .zero, size: self.documentSize) + var target = full + if self.usesCroppedViewport { + let left = max(0, min(1, self.renderViewportLeftRatio)) + let top = max(0, min(1, self.renderViewportTopRatio)) + let width = max(0.001, min(1 - left, self.renderViewportWidthRatio)) + let height = max(0.001, min(1 - top, self.renderViewportHeightRatio)) + target = CGRect(x: left * full.width, y: top * full.height, + width: width * full.width, height: height * full.height) + } + if self.continuousDocument { + // Initial prop delivery must never allocate a document-sized + // drawable even before the viewport ratios arrive. + let maxSide = 4096 / (self.baseContentScale() * self.clampedRenderScale) + target.size.width = min(target.width, maxSide) + target.size.height = min(target.height, maxSide) + } + if self.frame != target { self.frame = target } + self.applyDrawableContentScale(updateDrawableSize: true) + self.applyRenderViewportToEngine() + self.updateSelectionToolbarFrame() + self.requestDisplay(forceWhenSuspended: true) + } } // Store pending tool settings to apply when engine is ready @@ -139,24 +184,47 @@ class MobileInkCanvasView: MTKView { } } } + // Set before loading content; changing rendering mode recreates the engine. + @objc var continuousDocument: Bool = false { + didSet { + guard oldValue != continuousDocument else { return } + if drawingEngine != nil { + configureDrawingEngine(width: enginePixelWidth, height: enginePixelHeight, + preserveExistingDrawing: true) + } + scheduleRenderGeometryUpdate() + } + } + @objc var renderScale: CGFloat = 1.0 { didSet { - applyDrawableContentScale(updateDrawableSize: true) - applyRenderViewportToEngine() - requestDisplay(forceWhenSuspended: true) + guard oldValue != renderScale else { return } + scheduleRenderGeometryUpdate() } } @objc var renderViewportLeftRatio: CGFloat = 0.0 { - didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + didSet { + guard oldValue != renderViewportLeftRatio else { return } + scheduleRenderGeometryUpdate() + } } @objc var renderViewportTopRatio: CGFloat = 0.0 { - didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + didSet { + guard oldValue != renderViewportTopRatio else { return } + scheduleRenderGeometryUpdate() + } } @objc var renderViewportWidthRatio: CGFloat = 1.0 { - didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + didSet { + guard oldValue != renderViewportWidthRatio else { return } + scheduleRenderGeometryUpdate() + } } @objc var renderViewportHeightRatio: CGFloat = 1.0 { - didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + didSet { + guard oldValue != renderViewportHeightRatio else { return } + scheduleRenderGeometryUpdate() + } } @objc var onDrawingChange: RCTDirectEventBlock? @@ -363,9 +431,10 @@ class MobileInkCanvasView: MTKView { override func layoutSubviews() { super.layoutSubviews() - applyDrawableContentScale(updateDrawableSize: true) - applyRenderViewportToEngine() - updateSelectionToolbarFrame() + // Let the coalesced geometry update crop the frame before resizing + // its drawable. A layout between React prop setters must not allocate + // a temporary full-page texture at the new zoom level. + scheduleRenderGeometryUpdate() } private func baseContentScale() -> CGFloat { @@ -401,13 +470,7 @@ class MobileInkCanvasView: MTKView { return } - let actualScaleX = pixelWidth > 0 - ? CGFloat(pixelWidth) / CGFloat(enginePixelWidth) - : clampedRenderScale - let actualRenderScale = max( - 1.0, - min(5.0, actualScaleX.isFinite ? actualScaleX : clampedRenderScale) - ) + let actualRenderScale = clampedRenderScale let left = max(0.0, min(1.0, renderViewportLeftRatio)) * CGFloat(enginePixelWidth) let top = max(0.0, min(1.0, renderViewportTopRatio)) * CGFloat(enginePixelHeight) let widthRatio = max(0.001, min(1.0, renderViewportWidthRatio)) @@ -418,10 +481,10 @@ class MobileInkCanvasView: MTKView { setRenderViewport( engine, Float(actualRenderScale), - Float(left), - Float(top), - Float(max(1.0, width)), - Float(max(1.0, height)) + Float(continuousDocument ? frame.minX * baseContentScale() : left), + Float(continuousDocument ? frame.minY * baseContentScale() : top), + Float(max(1.0, continuousDocument ? frame.width * baseContentScale() : width)), + Float(max(1.0, continuousDocument ? frame.height * baseContentScale() : height)) ) } @@ -430,19 +493,10 @@ class MobileInkCanvasView: MTKView { return } - // Only redraw the background view when explicitly forced (e.g. - // unsuspending, theme change, PDF URI change). The background - // doesn't change due to user touches, and on PDF backgrounds - // a setNeedsDisplay triggers CGPDFPage.draw on the main - // thread (~10-100ms per page) -- doing that on EVERY touch - // event blocks stroke rendering and is the actual root cause - // of the "drawing super laggy on PDF" bug. MobileInkBackgroundView - // already calls setNeedsDisplay on its own property setters - // when its inputs (URI, theme, page numbers) change, so we - // don't need to mirror it here. - if forceWhenSuspended { - backgroundView?.setNeedsDisplay() - } + // Ink invalidation does not invalidate the separate paper/PDF view. + // Its content setters and UIView layout own background invalidation. + // In particular, zoom/viewport changes must not rerasterize a PDF + // on the main thread merely to present a sharper ink surface. setNeedsDisplay() } @@ -531,8 +585,8 @@ class MobileInkCanvasView: MTKView { var bounds: [Float] = [0, 0, 0, 0] getSelectionBounds(engine, &bounds) let rect = CGRect( - x: CGFloat(bounds[0]) / scaleX, - y: CGFloat(bounds[1]) / scaleY, + x: CGFloat(bounds[0]) / scaleX - frame.minX, + y: CGFloat(bounds[1]) / scaleY - frame.minY, width: CGFloat(bounds[2] - bounds[0]) / scaleX, height: CGFloat(bounds[3] - bounds[1]) / scaleY ) @@ -693,7 +747,7 @@ class MobileInkCanvasView: MTKView { override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard let engine = drawingEngine, let touch = touches.first else { return } - let location = touch.location(in: self) + let location = touch.location(in: superview ?? self) currentTouchLocation = location let scaledX = Float(location.x * scaleX) let scaledY = Float(location.y * scaleY) @@ -780,7 +834,7 @@ class MobileInkCanvasView: MTKView { } // Update cursor for all touch modes (but skip during selection move for performance) - let currentLocation = touch.location(in: self) + let currentLocation = touch.location(in: superview ?? self) currentTouchLocation = currentLocation if isTransformingSelection { @@ -802,7 +856,7 @@ class MobileInkCanvasView: MTKView { cancelHoldToShapePreview() isHoldToShapeStrokeActive = false // Moving existing selection - optimized path - let location = touch.location(in: self) + let location = touch.location(in: superview ?? self) let dx = Float((location.x - lastPoint.x) * scaleX) let dy = Float((location.y - lastPoint.y) * scaleY) @@ -972,6 +1026,15 @@ class MobileInkCanvasView: MTKView { } } + @objc func clearDocumentPage(_ pageIndex: Int32, pageHeight: Float) { + guard let engine = drawingEngine else { return } + let hadSelection = getSelectionCount(engine) > 0 + guard clearContinuousDocumentPage(engine, pageIndex, pageHeight) else { return } + endSelectionInteractionAfterHistoryChange(hadSelection: hadSelection) + requestDisplay() + onDrawingChange?([:]) + } + @objc func clear() { guard let engine = drawingEngine else { return } let hadSelection = getSelectionCount(engine) > 0 @@ -981,22 +1044,41 @@ class MobileInkCanvasView: MTKView { onDrawingChange?([:]) } + @objc func recordExternalEdit(_ before: String, after: String) -> Bool { + guard let engine = drawingEngine else { return false } + return before.withCString { beforeBytes in + after.withCString { afterBytes in commitExternalEdit(engine, beforeBytes, afterBytes) } + } + } + @objc func undo() { guard let engine = drawingEngine else { return } let hadSelection = getSelectionCount(engine) > 0 - undoStroke(engine) + let external = undoExternalEdit(engine) endSelectionInteractionAfterHistoryChange(hadSelection: hadSelection) requestDisplay() - onDrawingChange?([:]) + if let external { + let payload = String(cString: external) + free(external) + onDrawingChange?(["historyAction": "undo", "externalEdit": ["payload": payload]]) + } else { + onDrawingChange?(["historyAction": "undo"]) + } } @objc func redo() { guard let engine = drawingEngine else { return } let hadSelection = getSelectionCount(engine) > 0 - redoStroke(engine) + let external = redoExternalEdit(engine) endSelectionInteractionAfterHistoryChange(hadSelection: hadSelection) requestDisplay() - onDrawingChange?([:]) + if let external { + let payload = String(cString: external) + free(external) + onDrawingChange?(["historyAction": "redo", "externalEdit": ["payload": payload]]) + } else { + onDrawingChange?(["historyAction": "redo"]) + } } /// Undo, redo and clear rebuild the stroke list, so the engine drops its @@ -1035,7 +1117,8 @@ class MobileInkCanvasView: MTKView { // Show cursor with circle matching eraser size // Width is in canvas coordinates, need to convert to view coordinates let radiusInViewCoords = CGFloat(width) / (2.0 * scaleX) - let circlePath = UIBezierPath(arcCenter: location, + let localLocation = CGPoint(x: location.x - frame.minX, y: location.y - frame.minY) + let circlePath = UIBezierPath(arcCenter: localLocation, radius: radiusInViewCoords, startAngle: 0, endAngle: .pi * 2, @@ -1163,6 +1246,12 @@ class MobileInkCanvasView: MTKView { } @objc func loadBase64Data(_ base64String: String, callback: @escaping RCTResponseSenderBlock) { + // React attaches the native view before its first layout creates the + // engine. Report retryable readiness without treating valid ink as bad. + guard drawingEngine != nil else { + callback([NSNull(), false]) + return + } deferredBase64Payload = nil if let errorMessage = applyBase64Data(base64String) { callback([errorMessage, NSNull()]) @@ -1432,12 +1521,12 @@ class MobileInkCanvasView: MTKView { if let renderCanvas = renderCanvas { // Engine stores strokes in pixel coordinates, but we're exporting at page dimensions // Scale to convert: pixels → page → export scale (using minRenderScale for quality) - let pixelToPage = Float(pageWidth) / Float(pixelWidth) // e.g. 788 / 1576 = 0.5 + let pixelToPage = Float(pageWidth) / Float(max(1, enginePixelWidth)) // e.g. 788 / 1576 = 0.5 let totalScale = pixelToPage * Float(minRenderScale) // Use higher scale for quality scaleSkiaCanvas(renderCanvas, totalScale, totalScale) // Render engine strokes (in pixel coords) to the scaled canvas - renderToCanvas(engine, renderCanvas) + renderToCanvasForExport(engine, renderCanvas) destroySkiaCanvas(renderCanvas) } @@ -1577,7 +1666,9 @@ class MobileInkCanvasView: MTKView { drawingEngine = nil } - drawingEngine = createDrawingEngine(width, height) + drawingEngine = continuousDocument + ? createContinuousDrawingEngine(width, height) + : createDrawingEngine(width, height) enginePixelWidth = width enginePixelHeight = height @@ -1678,8 +1769,8 @@ extension MobileInkCanvasView: MTKViewDelegate { if isReleased { return } // Calculate page dimensions from bounds (for export) - if bounds.width > 0 && bounds.height > 0 { - pageWidth = bounds.width + if documentSize.width > 0 && documentSize.height > 0 { + pageWidth = documentSize.width pageHeight = pageWidth * (11.0 / 8.5) // US Letter aspect ratio } @@ -1691,11 +1782,11 @@ extension MobileInkCanvasView: MTKViewDelegate { let baseScale = baseContentScale() let nextEnginePixelWidth: Int32 let nextEnginePixelHeight: Int32 - if bounds.width > 0 && bounds.height > 0 { + if documentSize.width > 0 && documentSize.height > 0 { scaleX = baseScale scaleY = baseScale - nextEnginePixelWidth = Int32(max(1, round(bounds.width * baseScale))) - nextEnginePixelHeight = Int32(max(1, round(bounds.height * baseScale))) + nextEnginePixelWidth = Int32(max(1, round(documentSize.width * baseScale))) + nextEnginePixelHeight = Int32(max(1, round(documentSize.height * baseScale))) } else { nextEnginePixelWidth = 0 nextEnginePixelHeight = 0 @@ -1723,11 +1814,18 @@ extension MobileInkCanvasView: MTKViewDelegate { ) print("✅ Drawing engine created with size: \(pixelWidth)x\(pixelHeight) (page: \(pageWidth)x\(pageHeight))") } else if enginePixelWidth != nextEnginePixelWidth || enginePixelHeight != nextEnginePixelHeight { - configureDrawingEngine( - width: nextEnginePixelWidth, - height: nextEnginePixelHeight, - preserveExistingDrawing: true - ) + if continuousDocument, let engine = drawingEngine, + resizeContinuousDrawingEngine(engine, nextEnginePixelWidth, nextEnginePixelHeight) { + enginePixelWidth = nextEnginePixelWidth + enginePixelHeight = nextEnginePixelHeight + applyRenderViewportToEngine() + } else { + configureDrawingEngine( + width: nextEnginePixelWidth, + height: nextEnginePixelHeight, + preserveExistingDrawing: true + ) + } print("✅ Drawing engine resized to: \(pixelWidth)x\(pixelHeight) (page: \(pageWidth)x\(pageHeight))") } else { applyRenderViewportToEngine() @@ -1786,7 +1884,7 @@ extension MobileInkCanvasView: MTKViewDelegate { let freshDrawable = view.currentDrawable { let texturePtr = Unmanaged.passUnretained(freshDrawable.texture).toOpaque() let renderStart = CACurrentMediaTime() - if renderToGaneshMetalTexture(engine, ganeshContext, texturePtr, width, height) { + if renderToGaneshMetalTexture(engine, ganeshContext, texturePtr, width, height, usesCroppedViewport) { let renderDurationMs = (CACurrentMediaTime() - renderStart) * 1000.0 guard let commandBuffer = commandQueue.makeCommandBuffer() else { return @@ -1822,7 +1920,8 @@ extension MobileInkCanvasView: MTKViewDelegate { // Render Skia strokes to pixel buffer (no scaling - engine already in pixel coords) let canvas = createSkiaCanvas(UnsafeMutableRawPointer(buffer), width, height, Int32(bytesPerRow)) if let canvas = canvas { - renderToCanvas(engine, canvas) + if usesCroppedViewport { renderVisibleViewportToCanvas(engine, canvas) } + else { renderToCanvas(engine, canvas) } destroySkiaCanvas(canvas) } @@ -1915,7 +2014,7 @@ extension MobileInkCanvasView { } private func processTouchSample(engine: OpaquePointer, touch: UITouch) { - let location = touch.location(in: self) + let location = touch.location(in: superview ?? self) let pressure = normalizedPressure(for: touch) let (azimuth, altitude) = extractAzimuthAndAltitude(for: touch) let isPencilInput = PencilStrokeInputNormalizer.isPencil(touch) @@ -1929,7 +2028,7 @@ extension MobileInkCanvasView { // PREDICTIVE TOUCH: Process predicted touch samples for Apple Pencil low-latency rendering // These points are rendered temporarily and replaced by actual data on next touch event private func processPredictedTouchSample(engine: OpaquePointer, touch: UITouch) { - let location = touch.location(in: self) + let location = touch.location(in: superview ?? self) let pressure = normalizedPressure(for: touch) let (azimuth, altitude) = extractAzimuthAndAltitude(for: touch) let isPencilInput = PencilStrokeInputNormalizer.isPencil(touch) diff --git a/ios/MobileInkModule/MobileInkCanvasViewManager.m b/ios/MobileInkModule/MobileInkCanvasViewManager.m index 5cdee9f..28f1cd7 100644 --- a/ios/MobileInkModule/MobileInkCanvasViewManager.m +++ b/ios/MobileInkModule/MobileInkCanvasViewManager.m @@ -12,6 +12,7 @@ @interface RCT_EXTERN_MODULE(MobileInkCanvasViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(renderSuspended, BOOL) RCT_EXPORT_VIEW_PROPERTY(renderBackend, NSString) RCT_EXPORT_VIEW_PROPERTY(drawingPolicy, NSString) +RCT_EXPORT_VIEW_PROPERTY(continuousDocument, BOOL) RCT_EXPORT_VIEW_PROPERTY(renderScale, CGFloat) RCT_EXPORT_VIEW_PROPERTY(renderViewportLeftRatio, CGFloat) RCT_EXPORT_VIEW_PROPERTY(renderViewportTopRatio, CGFloat) @@ -19,6 +20,8 @@ @interface RCT_EXTERN_MODULE(MobileInkCanvasViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(renderViewportHeightRatio, CGFloat) RCT_EXTERN_METHOD(clear:(nonnull NSNumber *)node) +RCT_EXTERN_METHOD(clearDocumentPage:(nonnull NSNumber *)node pageIndex:(nonnull NSNumber *)pageIndex pageHeight:(nonnull NSNumber *)pageHeight) +RCT_EXTERN_METHOD(recordExternalEdit:(nonnull NSNumber *)node before:(NSString *)before after:(NSString *)after callback:(RCTResponseSenderBlock)callback) RCT_EXTERN_METHOD(undo:(nonnull NSNumber *)node) RCT_EXTERN_METHOD(redo:(nonnull NSNumber *)node) diff --git a/ios/MobileInkModule/MobileInkCanvasViewManager.swift b/ios/MobileInkModule/MobileInkCanvasViewManager.swift index 64c8ac7..5bea7b2 100644 --- a/ios/MobileInkModule/MobileInkCanvasViewManager.swift +++ b/ios/MobileInkModule/MobileInkCanvasViewManager.swift @@ -23,7 +23,8 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { override func layoutSubviews() { super.layoutSubviews() - backgroundView?.setNeedsDisplay() + if !continuousDocument { backgroundView?.setNeedsDisplay() } + drawingView?.updateDocumentLayout(bounds.size) drawingView?.requestDisplay(forceWhenSuspended: true) } @@ -89,6 +90,13 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { } } + @objc var continuousDocument: Bool = false { + didSet { + backgroundView?.isHidden = continuousDocument + drawingView?.continuousDocument = continuousDocument + } + } + @objc var renderScale: CGFloat = 1.0 { didSet { drawingView?.renderScale = renderScale @@ -116,6 +124,7 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { didSet { if let policy = drawingPolicy { drawingView?.drawingPolicy = policy + } } } @@ -146,6 +155,7 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { if let backend = renderBackend { view.renderBackend = backend } + view.continuousDocument = continuousDocument view.renderScale = renderScale view.renderViewportLeftRatio = renderViewportLeftRatio view.renderViewportTopRatio = renderViewportTopRatio @@ -156,6 +166,7 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { func setMobileInkBackgroundView(_ view: MobileInkBackgroundView) { self.backgroundView = view + view.isHidden = continuousDocument // Forward any properties that were set before the background view was added if let type = backgroundType { view.backgroundType = type @@ -193,7 +204,8 @@ class MobileInkCanvasViewManager: RCTViewManager { // Create and add drawing view on top (transparent Metal view) let drawingView = MobileInkCanvasView(frame: .zero, device: MTLCreateSystemDefaultDevice()) - drawingView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + // Drawing geometry is set from the visible document rectangle. + drawingView.autoresizingMask = [] drawingView.backgroundView = backgroundView // Set reference for export containerView.addSubview(drawingView) @@ -220,6 +232,15 @@ class MobileInkCanvasViewManager: RCTViewManager { // MARK: - Exposed Methods + @objc func clearDocumentPage(_ node: NSNumber, pageIndex: NSNumber, pageHeight: NSNumber) { + DispatchQueue.main.async { + if let container = self.bridge.uiManager.view(forReactTag: node), + let view = self.findDrawingView(container) { + view.clearDocumentPage(pageIndex.int32Value, pageHeight: pageHeight.floatValue) + } + } + } + @objc func clear(_ node: NSNumber) { DispatchQueue.main.async { if let container = self.bridge.uiManager.view(forReactTag: node), @@ -229,6 +250,14 @@ class MobileInkCanvasViewManager: RCTViewManager { } } + @objc func recordExternalEdit(_ node: NSNumber, before: String, after: String, callback: @escaping RCTResponseSenderBlock) { + DispatchQueue.main.async { + guard let container = self.bridge.uiManager.view(forReactTag: node), + let view = self.findDrawingView(container) else { callback([false]); return } + callback([view.recordExternalEdit(before, after: after)]) + } + } + @objc func undo(_ node: NSNumber) { DispatchQueue.main.async { if let container = self.bridge.uiManager.view(forReactTag: node), diff --git a/ios/MobileInkModule/MobileInkDocumentBridge.mm b/ios/MobileInkModule/MobileInkDocumentBridge.mm new file mode 100644 index 0000000..861d88d --- /dev/null +++ b/ios/MobileInkModule/MobileInkDocumentBridge.mm @@ -0,0 +1,176 @@ +#import +#import +#import +#include "cpp/ContinuousDocumentPages.h" +#include "cpp/DrawingSerialization.h" +#include +#include + +namespace { +using namespace nativedrawing; +ContinuousDocumentPage decodePage(NSString* payload) { + if (![payload isKindOfClass:NSString.class]) throw std::invalid_argument("Invalid page payload type"); + NSError* error = nil; + NSData* jsonData = [payload dataUsingEncoding:NSUTF8StringEncoding]; + if (!jsonData) throw std::invalid_argument("Invalid UTF-8 page payload"); + id root = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; + if (error || ![root isKindOfClass:NSDictionary.class] || ![root[@"pages"] isKindOfClass:NSDictionary.class]) + throw std::invalid_argument("Invalid page payload JSON"); + NSDictionary* encodedPages = root[@"pages"]; + if (encodedPages.count > 1 || (encodedPages.count == 1 && !encodedPages[@"0"])) + throw std::invalid_argument("Expected a single native page payload"); + ContinuousDocumentPage page; + if (encodedPages.count) { + id encoded = encodedPages[@"0"]; + if (![encoded isKindOfClass:NSString.class]) throw std::invalid_argument("Invalid drawing encoding"); + NSData* bytes = [[NSData alloc] initWithBase64EncodedString:encoded options:0]; + if (!bytes.length) throw std::invalid_argument("Invalid drawing bytes"); + const auto* begin = static_cast(bytes.bytes); + DrawingSerialization serializer; + if (!serializer.deserialize(std::vector(begin, begin + bytes.length), page.strokes)) + throw std::invalid_argument("Invalid serialized drawing"); + } + id order = root[@"documentStrokeOrder"]; + if (order) { + if (![order isKindOfClass:NSArray.class]) throw std::invalid_argument("Invalid document stroke order"); + for (id value in (NSArray*)order) { + if (![value isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) + throw std::invalid_argument("Invalid document stroke order value"); + double number = [value doubleValue]; + if (!std::isfinite(number) || number < 0 || number > 9007199254740991.0 || std::floor(number) != number) + throw std::invalid_argument("Invalid document stroke order range"); + page.order.push_back(static_cast(number)); + } + if (page.order.size() != page.strokes.size()) throw std::invalid_argument("Document stroke order count mismatch"); + } + return page; +} +NSString* encodePage(const ContinuousDocumentPage& page, bool includeOrder) { + NSMutableDictionary* root = [NSMutableDictionary dictionary]; + if (page.strokes.empty()) root[@"pages"] = @{}; + else { + DrawingSerialization serializer; + const auto bytes = serializer.serialize(page.strokes); + NSData* data = [NSData dataWithBytes:bytes.data() length:bytes.size()]; + root[@"pages"] = @{@"0": [data base64EncodedStringWithOptions:0]}; + } + if (includeOrder) { + NSMutableArray* order = [NSMutableArray arrayWithCapacity:page.order.size()]; + for (uint64_t value : page.order) [order addObject:@(value)]; + root[@"documentStrokeOrder"] = order; + } + NSError* error = nil; + NSData* json = [NSJSONSerialization dataWithJSONObject:root options:0 error:&error]; + if (error || !json) throw std::runtime_error("Cannot encode document page"); + return [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]; +} +} + +@protocol MobileInkDocumentLoader +- (void)loadBase64Data:(NSNumber*)viewTag base64String:(NSString*)payload + callback:(RCTResponseSenderBlock)callback; +@end + +@interface MobileInkDocumentBridge : NSObject +@end +@implementation MobileInkDocumentBridge +RCT_EXPORT_MODULE() +@synthesize bridge = _bridge; ++ (BOOL)requiresMainQueueSetup { return NO; } +- (dispatch_queue_t)methodQueue { return dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); } +RCT_EXPORT_METHOD(composeDocument:(NSArray*)payloads + pageHeight:(double)pageHeight + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + try { + std::vector pages; + pages.reserve(payloads.count); + for (NSString* payload in payloads) pages.push_back(decodePage(payload)); + nativedrawing::ContinuousDocumentPage document; + document.strokes = nativedrawing::composeContinuousDocument(pages, pageHeight); + resolve(encodePage(document, false)); + } catch (const std::exception& error) { + reject(@"DOCUMENT_COMPOSE_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } +} +// Compose off the UI thread, then reuse the canvas's confirmed asynchronous load. +// The combined payload never becomes a JavaScript string. +RCT_EXPORT_METHOD(loadDocument:(nonnull NSNumber*)viewTag + payloads:(NSArray*)payloads + pageHeight:(double)pageHeight + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + try { + std::vector pages; + pages.reserve(payloads.count); + for (NSString* payload in payloads) pages.push_back(decodePage(payload)); + nativedrawing::ContinuousDocumentPage document; + document.strokes = nativedrawing::composeContinuousDocument(pages, pageHeight); + NSString* payload = encodePage(document, false); + dispatch_async(dispatch_get_main_queue(), ^{ + id loader = (id)[self.bridge moduleForName:@"MobileInkCanvasViewManager"]; + if (![loader respondsToSelector:@selector(loadBase64Data:base64String:callback:)]) { + reject(@"DOCUMENT_LOAD_UNAVAILABLE", @"MobileInkCanvasViewManager is unavailable", nil); + return; + } + [loader loadBase64Data:viewTag base64String:payload callback:^(NSArray* result) { + id error = result.count > 0 ? result[0] : nil; + if ([error isKindOfClass:NSString.class]) { + // A just-mounted React view can become ready before its tag + // is registered. The caller retries false, never treats it as loaded. + if ([error isEqual:@"View not found"]) resolve(@NO); + else reject(@"DOCUMENT_LOAD_FAILED", error, nil); + } else { + resolve(@(result.count > 1 && [result[1] isKindOfClass:NSNumber.class] && [result[1] boolValue])); + } + }]; + }); + } catch (const std::exception& error) { + reject(@"DOCUMENT_LOAD_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } +} +RCT_EXPORT_METHOD(partitionDocument:(NSString*)payload + pageCount:(NSInteger)pageCount + pageHeight:(double)pageHeight + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + try { + if (pageCount <= 0 || pageCount > 100000) throw std::invalid_argument("Invalid document page count"); + auto document = decodePage(payload); + const auto pages = nativedrawing::partitionContinuousDocument(document.strokes, (int)pageCount, pageHeight); + NSMutableArray* result = [NSMutableArray arrayWithCapacity:pages.size()]; + for (const auto& page : pages) [result addObject:encodePage(page, true)]; + resolve(result); + } catch (const std::exception& error) { + reject(@"DOCUMENT_PARTITION_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } +} +RCT_EXPORT_METHOD(projectDocumentPages:(NSArray*)payloads + pageHeight:(double)pageHeight + pageIndices:(NSArray*)pageIndices + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + try { + std::vector pages; + for (NSString* payload in payloads) pages.push_back(decodePage(payload)); + std::vector indices; + for (NSNumber* value in pageIndices) { + if (![value isKindOfClass:NSNumber.class] || !std::isfinite(value.doubleValue) || + value.doubleValue < 0 || value.doubleValue >= payloads.count || + std::floor(value.doubleValue) != value.doubleValue) + throw std::invalid_argument("Invalid export page index"); + indices.push_back(value.intValue); + } + const auto projected = nativedrawing::projectContinuousDocumentPages(pages, pageHeight, indices); + NSMutableArray* result = [NSMutableArray arrayWithCapacity:projected.size()]; + for (const auto& strokes : projected) { + nativedrawing::ContinuousDocumentPage page; + page.strokes = strokes; + [result addObject:encodePage(page, false)]; + } + resolve(result); + } catch (const std::exception& error) { + reject(@"DOCUMENT_EXPORT_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } +} +@end diff --git a/ios/MobileInkModule/SkiaEngineBridge.swift b/ios/MobileInkModule/SkiaEngineBridge.swift index aafd71d..2f2287c 100644 --- a/ios/MobileInkModule/SkiaEngineBridge.swift +++ b/ios/MobileInkModule/SkiaEngineBridge.swift @@ -7,6 +7,12 @@ import Foundation @_silgen_name("createDrawingEngine") func createDrawingEngine(_ width: Int32, _ height: Int32) -> OpaquePointer +@_silgen_name("createContinuousDrawingEngine") +func createContinuousDrawingEngine(_ width: Int32, _ height: Int32) -> OpaquePointer + +@_silgen_name("resizeContinuousDrawingEngine") +func resizeContinuousDrawingEngine(_ engine: OpaquePointer, _ width: Int32, _ height: Int32) -> Bool + @_silgen_name("destroyDrawingEngine") func destroyDrawingEngine(_ engine: OpaquePointer) @@ -31,6 +37,9 @@ func clearPredictedPoints(_ engine: OpaquePointer) func addPredictedPoint(_ engine: OpaquePointer, _ x: Float, _ y: Float, _ pressure: Float, _ azimuth: Float, _ altitude: Float, _ timestamp: Int64, _ isPencilInput: Bool) // MARK: - Canvas Operations +@_silgen_name("clearContinuousDocumentPage") +func clearContinuousDocumentPage(_ engine: OpaquePointer, _ pageIndex: Int32, _ pageHeight: Float) -> Bool + @_silgen_name("clearCanvas") func clearCanvas(_ engine: OpaquePointer) @@ -70,6 +79,12 @@ func isEmpty(_ engine: OpaquePointer) -> Bool @_silgen_name("renderToCanvas") func renderToCanvas(_ engine: OpaquePointer, _ canvas: OpaquePointer) +@_silgen_name("renderVisibleViewportToCanvas") +func renderVisibleViewportToCanvas(_ engine: OpaquePointer, _ canvas: OpaquePointer) + +@_silgen_name("renderToCanvasForExport") +func renderToCanvasForExport(_ engine: OpaquePointer, _ canvas: OpaquePointer) + @_silgen_name("setRenderViewport") func setRenderViewport( _ engine: OpaquePointer, @@ -92,7 +107,8 @@ func renderToGaneshMetalTexture( _ context: OpaquePointer, _ texture: UnsafeMutableRawPointer, _ width: Int32, - _ height: Int32 + _ height: Int32, + _ cropped: Bool ) -> Bool // MARK: - Skia Canvas Helpers @@ -205,3 +221,11 @@ func nativeBatchExportPages( @_silgen_name("freeBatchExportResult") func freeBatchExportResult(_ result: UnsafeMutablePointer?) + +// Returned strings are malloc-owned and must be freed by the caller. +@_silgen_name("commitExternalEdit") +func commitExternalEdit(_ engine: OpaquePointer, _ before: UnsafePointer, _ after: UnsafePointer) -> Bool +@_silgen_name("undoExternalEdit") +func undoExternalEdit(_ engine: OpaquePointer) -> UnsafeMutablePointer? +@_silgen_name("redoExternalEdit") +func redoExternalEdit(_ engine: OpaquePointer) -> UnsafeMutablePointer? diff --git a/package-lock.json b/package-lock.json index 5ebfa15..bf0a81a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mathnotes/mobile-ink", - "version": "0.3.7", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mathnotes/mobile-ink", - "version": "0.3.7", + "version": "0.3.8", "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.25.2", diff --git a/package.json b/package.json index a8cfc04..f0d2986 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mathnotes/mobile-ink", - "version": "0.3.7", + "version": "0.3.8", "description": "Production-grade React Native ink engine with native Skia drawing and continuous canvas primitives.", "license": "Apache-2.0", "author": "BuilderPro LLC", @@ -47,8 +47,13 @@ "test:example:export:ios": "npm --prefix example run export:ios", "test:example:export:android": "npm --prefix example run export:android", "pack:dry-run": "npm pack --dry-run", - "validate": "npm run typecheck && npm run test && npm run test:release && npm run test:native:smoke && npm run test:native:eraser-smoke && npm run test:native:selection-smoke && npm run build && npm run pack:dry-run && npm run test:example:typecheck && npm run test:example:export:ios && npm run test:example:export:android", - "prepack": "npm run build" + "validate": "npm run typecheck && npm run test && npm run test:release && npm run test:native:smoke && npm run test:native:eraser-smoke && npm run test:native:selection-smoke && npm run test:native:zoom-smoke && npm run test:native:document-jni && npm run build && npm run pack:dry-run && npm run test:example:typecheck && npm run test:example:export:ios && npm run test:example:export:android", + "prepack": "npm run build", + "test:native:zoom-smoke": "clang++ -O2 -std=c++20 -fobjc-arc scripts/zoom_surface_smoke.mm cpp/*.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework Metal -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_zoom_surface_smoke && /tmp/mobile_ink_zoom_surface_smoke", + "benchmark:native:zoom": "clang++ -O2 -std=c++20 -fobjc-arc scripts/zoom_render_benchmark.mm cpp/*.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework Metal -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_zoom_render_benchmark && /tmp/mobile_ink_zoom_render_benchmark", + "benchmark:native:engine-memory": "clang++ -O2 -std=c++20 -fobjc-arc scripts/engine_memory_benchmark.mm cpp/*.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework Metal -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_engine_memory_benchmark && /tmp/mobile_ink_engine_memory_benchmark", + "benchmark:native:continuous": "clang++ -O2 -std=c++20 -fobjc-arc scripts/continuous_document_benchmark.mm cpp/*.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework Metal -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_continuous_document_benchmark && /tmp/mobile_ink_continuous_document_benchmark", + "test:native:document-jni": "bash scripts/test-document-jni.sh" }, "peerDependencies": { "@shopify/react-native-skia": ">=2.0.0", diff --git a/scripts/continuous_document_benchmark.mm b/scripts/continuous_document_benchmark.mm new file mode 100644 index 0000000..7ff8621 --- /dev/null +++ b/scripts/continuous_document_benchmark.mm @@ -0,0 +1,52 @@ +#include "SkiaDrawingEngine.h" +#include +#include +#include +#include + +uint64_t footprint() { + task_vm_info_data_t info{}; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + if (task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count) != KERN_SUCCESS) + throw std::runtime_error("Cannot read process physical footprint"); + return info.phys_footprint; +} +int main() { + constexpr int width = 2064, pageHeight = 2670, pages = 100; + const auto baseline = footprint(); + nativedrawing::SkiaDrawingEngine document(width, pageHeight * pages, true); + const auto constructed = footprint(); + document.setToolWithParams("pen", 4, 0x000000, "pixel"); + for (int page = 0; page < pages; ++page) { + for (int stroke = 0; stroke < 200; ++stroke) { + const float x = 30 + (stroke % 10) * 200; + const float y = page * pageHeight + 30 + (stroke / 10) * 130; + document.touchBegan(x, y, 1); + for (int point = 1; point < 32; ++point) + document.touchMoved(x + point * 4, y + (point % 2) * 5, 1); + document.touchEnded(); + } + } + const auto loaded = footprint(); + document.setRenderViewport(1, 0, pageHeight * 50, width, pageHeight); + const auto info = SkImageInfo::MakeN32Premul(width, pageHeight); + auto target = SkSurfaces::Raster(info); + if (!target) throw std::runtime_error("Cannot allocate viewport"); + const auto start = std::chrono::steady_clock::now(); + document.renderVisibleViewport(target->getCanvas()); + const double firstMs = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + const auto rendered = footprint(); + const auto cachedStart = std::chrono::steady_clock::now(); + document.renderVisibleViewport(target->getCanvas()); + const double cachedMs = std::chrono::duration(std::chrono::steady_clock::now() - cachedStart).count(); + document.touchBegan(100, pageHeight * 50 + 300, 1); + for (int point = 1; point < 50; ++point) + document.touchMoved(100 + point, pageHeight * 50 + 300 + point, 1); + document.renderVisibleViewport(target->getCanvas()); + const auto drawing = footprint(); + document.touchEnded(); + std::cout << "{\"pages\":100,\"strokes\":20000,\"pointsPerStroke\":32,\"baselineBytes\":" << baseline + << ",\"constructedBytes\":" << constructed << ",\"loadedBytes\":" << loaded + << ",\"renderedBytes\":" << rendered << ",\"drawingBytes\":" << drawing + << ",\"firstRenderMs\":" << firstMs << ",\"cachedRenderMs\":" << cachedMs << "}\n"; +} diff --git a/scripts/document-jni/DocumentCodec.java b/scripts/document-jni/DocumentCodec.java new file mode 100644 index 0000000..a29aa24 --- /dev/null +++ b/scripts/document-jni/DocumentCodec.java @@ -0,0 +1,8 @@ +package com.mathnotes.mobileink; +// Host fallback signatures; use DOCUMENT_CODEC_CLASSPATH for compiled Kotlin. +public final class DocumentCodec { + public static final DocumentCodec INSTANCE = new DocumentCodec(); + public native byte[] compose(byte[][] bytes, long[][] orders, boolean[] hasOrders, float height); + public native DocumentPageData[] partition(byte[] bytes, long[] order, boolean hasOrder, int count, float height); + public native DocumentPageData[] project(byte[][] bytes, long[][] orders, boolean[] hasOrders, float height, int[] indices); +} diff --git a/scripts/document-jni/DocumentCodecSmoke.java b/scripts/document-jni/DocumentCodecSmoke.java new file mode 100644 index 0000000..36d5e7b --- /dev/null +++ b/scripts/document-jni/DocumentCodecSmoke.java @@ -0,0 +1,47 @@ +package com.mathnotes.mobileink; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +public final class DocumentCodecSmoke { + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } + private static void rejects(Runnable work) { + try { work.run(); } catch (IllegalArgumentException expected) { return; } + throw new AssertionError("Invalid document accepted"); + } + public static void main(String[] args) throws Exception { + System.load(args[0]); + DocumentCodec codec = DocumentCodec.INSTANCE; + byte[] original = Files.readAllBytes(Path.of(args[1])); + DocumentPageData[] pages = codec.partition(original, new long[0], false, 2, 1000); + require(pages.length == 2, "Page count changed"); + require(Arrays.equals(pages[0].getOrder(), new long[]{1}) && Arrays.equals(pages[1].getOrder(), new long[]{0}), + "Crossing strokes were split or reordered"); + byte[][] bytes = { pages[0].getBytes(), pages[1].getBytes() }; + long[][] orders = { pages[0].getOrder(), pages[1].getOrder() }; + boolean[] modern = { true, true }; + byte[] restored = codec.compose(bytes, orders, modern, 1000); + require(Arrays.equals(original, restored), "Roundtrip changed geometry or draw order"); + DocumentPageData[] exports = codec.project(bytes, orders, modern, 1000, new int[]{0, 1}); + for (DocumentPageData page : exports) { + // Both crossing strokes must be present in each rendered page. + DocumentPageData[] projected = codec.partition(page.getBytes(), new long[0], false, 1, 1000); + require(projected[0].getOrder().length == 2, "Export omitted neighboring overflow"); + } + rejects(() -> codec.compose(bytes, new long[][]{{0}, {0}}, modern, 1000)); + rejects(() -> codec.partition(original, new long[0], true, 2, 1000)); + rejects(() -> codec.partition(new byte[]{1, 2}, new long[0], false, 2, 1000)); + rejects(() -> codec.partition(original, new long[0], false, 0, 1000)); + rejects(() -> codec.compose(bytes, orders, modern, Float.NaN)); + rejects(() -> codec.project(bytes, orders, modern, 1000, new int[]{2})); + byte[][] blanks = new byte[30000][]; + long[][] blankOrders = new long[30000][]; + Arrays.fill(blanks, new byte[0]); Arrays.fill(blankOrders, new long[0]); + require(codec.compose(blanks, blankOrders, new boolean[30000], 1000).length == 0, + "Large empty document failed"); + require(codec.partition(new byte[0], new long[0], true, 30000, 1000).length == 30000, + "Large partition failed"); + System.out.println("Document JNI host smoke passed: crossing, order, export, invalid input, 30k pages"); + } +} diff --git a/scripts/document-jni/DocumentPageData.java b/scripts/document-jni/DocumentPageData.java new file mode 100644 index 0000000..8b0d6cf --- /dev/null +++ b/scripts/document-jni/DocumentPageData.java @@ -0,0 +1,10 @@ +package com.mathnotes.mobileink; + +// Mirrors the Kotlin JNI result constructor for the host boundary test. +public final class DocumentPageData { + private final byte[] bytes; + private final long[] order; + public DocumentPageData(byte[] bytes, long[] order) { this.bytes = bytes; this.order = order; } + public byte[] getBytes() { return bytes; } + public long[] getOrder() { return order; } +} diff --git a/scripts/document-jni/README.md b/scripts/document-jni/README.md new file mode 100644 index 0000000..16cd594 --- /dev/null +++ b/scripts/document-jni/README.md @@ -0,0 +1,9 @@ +# Document JNI boundary check + +Run `bash scripts/test-document-jni.sh` on macOS with Java 17 and installed Skia dependencies. It compiles the production `android/src/main/cpp/document_bridge.cpp` against the shared C++ codec and runs it through a host JVM with `-Xcheck:jni`. + +The fixture contains two strokes crossing a page boundary in opposite directions. Checks cover whole-stroke ownership, draw ordering, byte-for-byte persistence, neighboring overflow in export, malformed payloads, duplicate ordering, invalid dimensions/indices, and 30,000-page documents to exercise bounded JNI local references. Build files live in a temporary directory removed on exit. + +This validates JNI descriptors and codec behavior on the host; it does not replace Android Kotlin/Gradle compilation, R8 verification, or device rendering tests. Java classes mirror the production Kotlin native method descriptors and result constructor. + +Set `DOCUMENT_CODEC_CLASSPATH` to the compiled production Kotlin classes plus Kotlin standard library to run the same tests against the actual `DocumentCodec` and `DocumentPageData` bytecode instead of the Java signature mirrors. diff --git a/scripts/document-jni/fixture.cpp b/scripts/document-jni/fixture.cpp new file mode 100644 index 0000000..2a57792 --- /dev/null +++ b/scripts/document-jni/fixture.cpp @@ -0,0 +1,44 @@ +#include "DrawingSerialization.h" +#include "ContinuousDocumentPages.h" +#include +#include +using namespace nativedrawing; +int main(int argc, char** argv) { + if (argc != 2) return 1; + std::vector strokes; + for (const auto ends : {std::pair{1100, 900}, {920, 1080}}) { + Stroke stroke; + stroke.paint.setColor(SK_ColorBLACK); + stroke.paint.setStrokeWidth(4); + stroke.paint.setStyle(SkPaint::kStroke_Style); + stroke.paint.setAntiAlias(true); + for (float y : {ends.first, ends.second}) { + Point point{}; + point.x = 100; point.y = y; point.pressure = 1; point.calculatedWidth = 4; + stroke.points.push_back(point); + } + strokes.push_back(stroke); + } + // A copied owner page must receive local targets before being inserted as + // legacy data. Reusing its original document IDs would collide on compose. + auto erased = strokes; + erased[1].affectedStrokeIndices.insert(1); + auto pages = partitionContinuousDocument(erased, 2, 1000); + auto localCopy = composeContinuousDocument({pages[0]}, 1000); + assert(localCopy.size() == 1); + assert(localCopy[0].affectedStrokeIndices.count(0) == 1); + ContinuousDocumentPage copy; + copy.strokes = localCopy; + pages.insert(pages.begin() + 1, copy); + const auto duplicated = composeContinuousDocument(pages, 1000); + assert(duplicated.size() == 3); + assert(duplicated[1].affectedStrokeIndices.count(1) == 1); + assert(duplicated[2].affectedStrokeIndices.count(2) == 1); + assert(duplicated[2].affectedStrokeIndices.count(1) == 0); + assert(duplicated[2].points.back().y > 2000); // full overflow retained + DrawingSerialization serializer; + const auto bytes = serializer.serialize(strokes); + std::ofstream output(argv[1], std::ios::binary); + output.write(reinterpret_cast(bytes.data()), bytes.size()); + return output ? 0 : 1; +} diff --git a/scripts/engine_memory_benchmark.mm b/scripts/engine_memory_benchmark.mm new file mode 100644 index 0000000..8b9c9f5 --- /dev/null +++ b/scripts/engine_memory_benchmark.mm @@ -0,0 +1,30 @@ +#include "SkiaDrawingEngine.h" +#include +#include +#include +#include +#include + +uint64_t footprint() { + task_vm_info_data_t info{}; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + if (task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count) != KERN_SUCCESS) { + throw std::runtime_error("Could not read process physical footprint"); + } + return info.phys_footprint; +} +int main() { + try { + auto before = footprint(); + std::vector> engines; + for (int i = 0; i < 3; i++) { + engines.push_back(std::make_unique(2064, 2670)); + } + auto after = footprint(); + std::cout << "{\"before\":" << before << ",\"after\":" << after + << ",\"delta\":" << (static_cast(after) - static_cast(before)) << "}\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/scripts/selection_history_smoke.cpp b/scripts/selection_history_smoke.cpp index 24f231b..b1b8fd8 100644 --- a/scripts/selection_history_smoke.cpp +++ b/scripts/selection_history_smoke.cpp @@ -156,6 +156,55 @@ void objectEraseDropsLiveSelection() { check(strokeAt(engine, 400.0f), "delete after object erase never removes an unselected stroke"); } +void externalEditsShareInkHistory() { + SkiaDrawingEngine engine(820, 1061); + drawStroke(engine, 200.0f); + const auto firstInk = engine.serializeDrawing(); + engine.selectStrokeAt(150.0f, 200.0f); + engine.beginSelectionTransform(0); + check(!engine.commitExternalEdit("before selection", "after selection"), "cannot commit an overlay during an ink transform"); + engine.cancelSelectionTransform(); + engine.clearSelection(); + check(engine.commitExternalEdit("before image", "after image"), "records an asset edit"); + check(engine.serializeDrawing() == firstInk, "recording overlay history does not change persisted ink"); + drawStroke(engine, 400.0f); + const auto bothInk = engine.serializeDrawing(); + check(engine.undo().empty(), "latest ink undoes before earlier asset"); + check(engine.serializeDrawing() == firstInk, "ink undo preserves first stroke"); + check(engine.undo() == "before image", "next undo returns exact asset before patch"); + check(engine.serializeDrawing() == firstInk, "asset undo leaves ink unchanged"); + check(engine.redo() == "after image", "redo returns exact asset after patch"); + check(engine.redo().empty(), "ink redoes after asset"); + check(engine.serializeDrawing() == bothInk, "combined redo restores original ink bytes"); + engine.undo(); + check(engine.commitExternalEdit("before next", "after next"), "new asset edit after ink undo"); + check(!engine.canRedo(), "new asset edit discards ink redo branch"); + check(engine.undo() == "before next", "new asset edit is next undo"); + check(engine.undo() == "before image", "earlier asset history survives branch edit"); + check(engine.deserializeDrawing(bothInk), "reload accepts ink bytes"); + check(!engine.canUndo() && !engine.canRedo(), "reload clears session-only overlay history"); +} + +void externalHistoryIsBoundedAndRejectsInvalidCommits() { + SkiaDrawingEngine engine(820, 1061); + check(!engine.commitExternalEdit("", "after"), "empty external patch rejected"); + check(!engine.commitExternalEdit("same", "same"), "unchanged external patch rejected"); + engine.touchBegan(100, 100, 1); + check(!engine.commitExternalEdit("before", "after"), "cannot interleave an external edit inside an unfinished stroke"); + engine.touchEnded(); + for (int i = 0; i < 25; ++i) { + check(engine.commitExternalEdit("before" + std::to_string(i), "after" + std::to_string(i)), "records bounded external history"); + } + for (int i = 24; i >= 5; --i) { + check(engine.undo() == "before" + std::to_string(i), "external undo order matches native bounded stack"); + } + check(!engine.canUndo(), "external entries obey native20 entry cap"); + for (int i = 5; i <= 24; ++i) { + check(engine.redo() == "after" + std::to_string(i), "external redo order matches native bounded stack"); + } + check(!engine.canRedo(), "redo stack exhausted exactly once"); +} + } // namespace int main() { @@ -165,6 +214,8 @@ int main() { undoCancelsInFlightTransform(); undoDropsPendingObjectErase(); objectEraseDropsLiveSelection(); + externalEditsShareInkHistory(); + externalHistoryIsBoundedAndRejectsInvalidCommits(); if (g_failures == 0) { std::cout << "Selection history smoke tests passed" << std::endl; diff --git a/scripts/test-document-jni.sh b/scripts/test-document-jni.sh new file mode 100644 index 0000000..e5f00b8 --- /dev/null +++ b/scripts/test-document-jni.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +java_root=$(/usr/libexec/java_home -v 17) +work=$(mktemp -d /private/tmp/mobile-ink-document-jni.XXXXXX) +trap 'rm -rf "$work"' EXIT +skia=node_modules/@shopify/react-native-skia +flags=(-std=c++17 -I cpp -I "$skia/cpp/skia" -I "$java_root/include" -I "$java_root/include/darwin") +sources=(cpp/ContinuousDocumentPages.cpp cpp/DrawingSerialization.cpp cpp/DrawingTypes.cpp cpp/ShapeRecognition.cpp) +libs=("$skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a" "$skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a" -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore) +clang++ "${flags[@]}" -dynamiclib android/src/main/cpp/document_bridge.cpp "${sources[@]}" "${libs[@]}" -o "$work/libmobileink.dylib" +clang++ "${flags[@]}" scripts/document-jni/fixture.cpp "${sources[@]}" "${libs[@]}" -o "$work/fixture" +"$work/fixture" "$work/document.bin" +if [[ -n "${DOCUMENT_CODEC_CLASSPATH:-}" ]]; then + "$java_root/bin/javac" -cp "$DOCUMENT_CODEC_CLASSPATH" -d "$work/classes" scripts/document-jni/DocumentCodecSmoke.java + classpath="$work/classes:$DOCUMENT_CODEC_CLASSPATH" +else + "$java_root/bin/javac" -d "$work/classes" scripts/document-jni/*.java + classpath="$work/classes" +fi +"$java_root/bin/java" -Xcheck:jni -Djava.library.path="$work" -cp "$classpath" com.mathnotes.mobileink.DocumentCodecSmoke "$work/libmobileink.dylib" "$work/document.bin" diff --git a/scripts/zoom_render_benchmark.mm b/scripts/zoom_render_benchmark.mm new file mode 100644 index 0000000..6601856 --- /dev/null +++ b/scripts/zoom_render_benchmark.mm @@ -0,0 +1,133 @@ +#import +#include "SkiaDrawingEngine.h" +#include "include/core/SkSurface.h" +#include "include/gpu/ganesh/GrDirectContext.h" +#include "include/gpu/ganesh/SkSurfaceGanesh.h" +#include "include/gpu/ganesh/mtl/GrMtlBackendContext.h" +#include "include/gpu/ganesh/mtl/GrMtlDirectContext.h" +#include +#include +#include +#include +#include + +using nativedrawing::SkiaDrawingEngine; +using Clock = std::chrono::steady_clock; + +namespace { +constexpr int kWidth = 1640; +constexpr int kHeight = 2122; + +double milliseconds(Clock::time_point start, Clock::time_point end) { + return std::chrono::duration(end - start).count(); +} + +sk_sp makeContext() { + id device = MTLCreateSystemDefaultDevice(); + if (!device) throw std::runtime_error("Metal device unavailable"); + id queue = [device newCommandQueue]; + GrMtlBackendContext backend{}; + backend.fDevice.retain((__bridge void*)device); + backend.fQueue.retain((__bridge void*)queue); + auto context = GrDirectContexts::MakeMetal(backend); + if (!context) throw std::runtime_error("Metal context unavailable"); + return context; +} + +void seed(SkiaDrawingEngine& engine, int count) { + engine.setToolWithParams("pen", 3, 0x000000, "pixel"); + const int rows = (count + 19) / 20; + for (int i = 0; i < count; ++i) { + const float x = 40 + (i % 20) * 78; + const float y = 40 + (i / 20) * (2000.f / rows); + engine.touchBegan(x, y, 1); + for (int j = 1; j < 32; ++j) { + engine.touchMoved(x + j * 2, y + std::sin(j * .5f) * 6, 1); + } + engine.touchEnded(); + } +} + +struct Frame { + const char* name; + float scale; + float left; + float top; +}; + +// Repeat within a context to separate the initial render, new scale tiles, +// cache hits, and newly revealed tiles. These are engine timings, not UI FPS. +constexpr Frame kFrames[] = { + {"initial", 1, 0, 0}, + {"zoom_2_new", 2, 350, 400}, + {"zoom_2_cached", 2, 350, 400}, + {"zoom_2_pan_new", 2, 1000, 1500}, + {"zoom_2_pan_cached", 2, 1000, 1500}, + {"zoom_2_5_new", 2.5f, 350, 400}, + {"zoom_2_5_cached", 2.5f, 350, 400}, + {"return_1", 1, 0, 0}, +}; + +void run(int count, int trial, bool metal, bool cropped) { + auto context = metal ? makeContext() : nullptr; + SkiaDrawingEngine engine(kWidth, kHeight); + seed(engine, count); + sk_sp surface; + float surfaceScale = 0; + for (const auto& frame : kFrames) { + double allocationMs = 0; + if (surfaceScale != frame.scale) { + const auto start = Clock::now(); + const auto info = SkImageInfo::MakeN32Premul( + cropped ? 820 : kWidth * frame.scale, + cropped ? 1061 : kHeight * frame.scale); + surface = metal + ? SkSurfaces::RenderTarget(context.get(), skgpu::Budgeted::kYes, info) + : SkSurfaces::Raster(info); + if (!surface) throw std::runtime_error("Surface allocation failed"); + allocationMs = milliseconds(start, Clock::now()); + surfaceScale = frame.scale; + } + const auto start = Clock::now(); + engine.setRenderViewport(frame.scale, frame.left, frame.top, + 820 / frame.scale, 1061 / frame.scale); + if (cropped) engine.renderVisibleViewport(surface->getCanvas()); + else engine.render(surface->getCanvas()); + const auto submitted = Clock::now(); + if (context) { + context->flush(surface.get()); + if (!context->submit(GrSyncCpu::kYes)) { + throw std::runtime_error("GPU submission failed"); + } + } + const auto done = Clock::now(); + std::cout << "{\"backend\":\"" << (metal ? "metal" : "raster") + << "\",\"trial\":" << trial << ",\"strokes\":" << count + << ",\"frame\":\"" << frame.name << "\",\"scale\":" << frame.scale + << ",\"cropped\":" << (cropped ? "true" : "false") + << ",\"target_bytes\":" << surface->width() * surface->height() * 4LL + << ",\"allocation_ms\":" << allocationMs + << ",\"cpu_record_ms\":" << milliseconds(start, submitted) + << ",\"completed_render_ms\":" << milliseconds(start, done) + << "}\n"; + } +} +} + +int main(int argc, char** argv) { + try { + if ((argc != 2 && argc != 3) || (std::string(argv[1]) != "metal" && std::string(argv[1]) != "raster")) { + throw std::runtime_error("Usage: zoom_render_benchmark metal|raster [crop]"); + } + const bool metal = std::string(argv[1]) == "metal"; + const bool cropped = argc == 3 && std::string(argv[2]) == "crop"; + if (argc == 3 && !cropped) throw std::runtime_error("Unknown target mode"); + for (int count : {100, 600, 2000}) { + for (int trial = 0; trial < 5; ++trial) run(count, trial, metal, cropped); + } + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/scripts/zoom_surface_smoke.mm b/scripts/zoom_surface_smoke.mm new file mode 100644 index 0000000..4928656 --- /dev/null +++ b/scripts/zoom_surface_smoke.mm @@ -0,0 +1,277 @@ +#include "ContinuousDocumentPages.h" +#include "DrawingSerialization.h" +#import +#include "../cpp/SkiaDrawingEngine.h" +#include "include/core/SkBitmap.h" +#include "include/gpu/ganesh/GrDirectContext.h" +#include "include/gpu/ganesh/SkSurfaceGanesh.h" +#include "include/gpu/ganesh/mtl/GrMtlBackendContext.h" +#include "include/gpu/ganesh/mtl/GrMtlDirectContext.h" +#include +#include + +using nativedrawing::SkiaDrawingEngine; + +namespace { +constexpr int kSize = 1024; + +sk_sp makeContext() { + id device = MTLCreateSystemDefaultDevice(); + id queue = [device newCommandQueue]; + GrMtlBackendContext backend{}; + backend.fDevice.retain((__bridge void*)device); + backend.fQueue.retain((__bridge void*)queue); + auto context = GrDirectContexts::MakeMetal(backend); + if (!context) throw std::runtime_error("Metal context unavailable"); + return context; +} + +SkBitmap render(SkiaDrawingEngine& engine, GrDirectContext* context, bool exportPage = false, int size = kSize) { + auto info = SkImageInfo::MakeN32Premul(size, size); + auto surface = context + ? SkSurfaces::RenderTarget(context, skgpu::Budgeted::kYes, info) + : SkSurfaces::Raster(info); + if (!surface) throw std::runtime_error("Surface allocation failed"); + if (exportPage) engine.renderForExport(surface->getCanvas()); + else engine.render(surface->getCanvas()); + SkBitmap pixels; + pixels.allocPixels(info); + // readPixels waits for GPU completion, so assertions inspect actual output. + if (!surface->readPixels(pixels, 0, 0)) throw std::runtime_error("Pixel readback failed"); + return pixels; +} + +SkBitmap renderCrop(SkiaDrawingEngine& engine, GrDirectContext* context, int width, int height) { + const auto info = SkImageInfo::MakeN32Premul(width, height); + auto surface = context + ? SkSurfaces::RenderTarget(context, skgpu::Budgeted::kYes, info) + : SkSurfaces::Raster(info); + if (!surface) throw std::runtime_error("Crop surface allocation failed"); + const auto originalMatrix = surface->getCanvas()->getLocalToDevice(); + engine.renderVisibleViewport(surface->getCanvas()); + if (surface->getCanvas()->getLocalToDevice() != originalMatrix) + throw std::runtime_error("Crop leaked its canvas transform"); + SkBitmap pixels; + pixels.allocPixels(info); + if (!surface->readPixels(pixels, 0, 0)) throw std::runtime_error("Crop readback failed"); + return pixels; +} + +void expectInk(const SkBitmap& pixels, int x, int y, bool ink, const char* label) { + const auto color = pixels.getColor(x, y); + const bool actual = SkColorGetR(color) < 100 && SkColorGetG(color) < 100; + if (actual != ink) throw std::runtime_error(label); +} + +void drawLine(SkiaDrawingEngine& engine) { + engine.setToolWithParams("pen", 10, 0x000000, "pixel"); + engine.touchBegan(40, 100, 1); + for (int x = 45; x <= 450; x += 5) engine.touchMoved(x, 100, 1); + engine.touchEnded(); +} +} + +int main() { + try { + auto first = makeContext(); + auto second = makeContext(); + // First use must allocate the live-stroke cache; a following stroke + // must clear its old snapshot without removing committed ink. + SkiaDrawingEngine live(512, 512); + live.setToolWithParams("pen", 10, 0x000000, "pixel"); + for (int y : {100, 180}) { + live.touchBegan(40, y, 1); + for (int x = 45; x <= 250; x += 5) live.touchMoved(x, y, 1); + expectInk(render(live, nullptr), 100, y, true, "First live stroke or reused cache missing"); + live.setRenderViewport(2, 0, 0, 256, 256); + expectInk(render(live, first.get()), 200, y * 2, true, "Live stroke lost on viewport change"); + live.touchEnded(); + live.setRenderViewport(1, 0, 0, 512, 512); + expectInk(render(live, nullptr), 100, 100, true, "Starting a stroke erased committed ink"); + } + SkiaDrawingEngine engine(512, 512); + drawLine(engine); + engine.setRenderViewport(2, 0, 0, 512, 512); + for (auto* context : {first.get(), static_cast(nullptr), second.get(), first.get()}) { + auto pixels = render(engine, context); + expectInk(pixels, 240, 200, true, "Stroke lost when switching render surfaces"); + expectInk(pixels, 240, 100, false, "Stroke rendered at wrong zoom"); + for (int x = 509; x <= 515; ++x) { + expectInk(pixels, x, 200, true, "Gap at a zoom tile boundary"); + } + } + // A zoomed viewport must render into a small target, independent of + // full page dimensions. Compare it pixel-for-pixel with the same crop + // of a full render, on both raster and real Metal surfaces. + for (int scale : {1, 2, 5}) { + engine.setRenderViewport(scale, 80, 60, 128, 96); + for (auto* context : {first.get(), static_cast(nullptr)}) { + auto full = render(engine, context, false, 512 * scale); + auto crop = renderCrop(engine, context, 128 * scale, 96 * scale); + for (int y = 0; y < crop.height(); ++y) { + for (int x = 0; x < crop.width(); ++x) { + if (crop.getColor(x, y) != full.getColor(x + 80 * scale, y + 60 * scale)) + throw std::runtime_error("Viewport crop differs from full-page render"); + } + } + expectInk(crop, 40 * scale, 40 * scale, true, "Viewport omitted visible ink"); + expectInk(render(engine, nullptr, true), 120, 100, true, "Cropped viewport changed page export"); + } + } + engine.setRenderViewport(2, 0, 0, 512, 512); + engine.undo(); + expectInk(render(engine, first.get()), 240, 200, false, "Undo left stale zoom tiles"); + engine.redo(); + expectInk(render(engine, first.get()), 240, 200, true, "Redo did not restore zoom tiles"); + + engine.setToolWithParams("eraser", 28, 0x000000, "pixel"); + engine.touchBegan(120, 85, 1); + for (int y = 90; y <= 120; y += 5) engine.touchMoved(120, y, 1); + engine.touchEnded(); + expectInk(render(engine, first.get()), 240, 200, false, "Erased pixels survived cached GPU tiles"); + expectInk(render(engine, nullptr, true), 120, 100, false, "Export lost erasure"); + expectInk(render(engine, nullptr, true), 65, 100, true, "Export lost remaining stroke"); + expectInk(render(engine, first.get()), 130, 200, true, "Export changed viewport scale"); + engine.clear(); + expectInk(render(engine, first.get()), 130, 200, false, "Pool reuse left cached ink"); + // Save page ownership without splitting a crossing stroke or sorting + // overlapping ink by page instead of its original drawing order. + { + SkiaDrawingEngine original(512, 2000, true); + original.setToolWithParams("pen", 10, 0x000000, "pixel"); + for (auto ends : {std::pair{1100, 900}, {920, 1080}}) { + const int direction = ends.second > ends.first ? 5 : -5; + original.touchBegan(100, ends.first, 1); + for (int y = ends.first + direction; y != ends.second + direction; y += direction) + original.touchMoved(100, y, 1); + original.touchEnded(); + } + const auto originalBytes = original.serializeDrawing(); + nativedrawing::DrawingSerialization serializer; + std::vector strokes; + if (!serializer.deserialize(originalBytes, strokes)) throw std::runtime_error("Cannot decode document fixture"); + auto pages = nativedrawing::partitionContinuousDocument(strokes, 2, 1000); + if (pages[0].strokes.size() != 1 || pages[1].strokes.size() != 1 || + pages[0].order[0] != 1 || pages[1].order[0] != 0) + throw std::runtime_error("Document partition split or reordered crossing strokes"); + for (auto& page : pages) { + const auto bytes = serializer.serialize(page.strokes); + if (!serializer.deserialize(bytes, page.strokes)) throw std::runtime_error("Page roundtrip failed"); + } + const auto restored = nativedrawing::composeContinuousDocument(pages, 1000); + if (serializer.serialize(restored) != originalBytes) + throw std::runtime_error("Page ownership roundtrip changed stroke geometry or ordering"); + const auto exportPages = nativedrawing::projectContinuousDocumentPages(pages, 1000, {0, 1}); + for (int pageIndex = 0; pageIndex < 2; ++pageIndex) { + if (exportPages[pageIndex].size() != 2) + throw std::runtime_error("Export projection omitted neighboring crossing ink"); + SkiaDrawingEngine pageEngine(512, 1000); + if (!pageEngine.deserializeDrawing(serializer.serialize(exportPages[pageIndex]))) + throw std::runtime_error("Cannot load projected export page"); + pageEngine.setRenderViewport(1, 0, 0, 512, 1000); + expectInk(render(pageEngine, nullptr, true), 100, pageIndex == 0 ? 950 : 50, true, + "Bounded page export lost crossing ink"); + } + if (!original.clearDocumentPage(0, 1000)) throw std::runtime_error("Page clear failed"); + std::vector remaining; + if (!serializer.deserialize(original.serializeDrawing(), remaining) || remaining.size() != 1 || + remaining[0].points.front().y != 1100) + throw std::runtime_error("Page clear removed another page or split a crossing stroke"); + original.undo(); + if (original.serializeDrawing() != originalBytes) + throw std::runtime_error("Page clear undo did not restore document exactly"); + original.redo(); + if (!serializer.deserialize(original.serializeDrawing(), remaining) || remaining.size() != 1) + throw std::runtime_error("Page clear redo failed"); + if (original.clearDocumentPage(2, 1000) || original.clearDocumentPage(-1, 1000) || + original.clearDocumentPage(0, 0)) + throw std::runtime_error("Invalid page clear accepted"); + // Empty-page clear must not consume undo history. + original.clearDocumentPage(0, 1000); + original.undo(); + if (original.serializeDrawing() != originalBytes) + throw std::runtime_error("Empty page clear consumed undo history"); + // Preserve cross-owner legacy eraser references through reordering. + strokes[1].isEraser = true; + strokes[1].affectedStrokeIndices = {0}; + strokes[0].erasedBy.push_back({100, 1000, 8}); + auto erasedPages = nativedrawing::partitionContinuousDocument(strokes, 2, 1000); + const auto erasedRoundtrip = nativedrawing::composeContinuousDocument(erasedPages, 1000); + if (erasedRoundtrip[1].affectedStrokeIndices != strokes[1].affectedStrokeIndices || + erasedRoundtrip[0].erasedBy[0].y != 1000) + throw std::runtime_error("Document partition lost eraser metadata"); + auto corruptPages = pages; + corruptPages[1].order[0] = corruptPages[0].order[0]; + bool rejected = false; + try { nativedrawing::composeContinuousDocument(corruptPages, 1000); } + catch (const std::invalid_argument&) { rejected = true; } + if (!rejected) throw std::runtime_error("Duplicate stroke order was silently accepted"); + } + // A 100-page document must keep one editable stroke across a page + // boundary without allocating a full-document raster surface. + for (auto* context : {static_cast(nullptr), first.get()}) { + SkiaDrawingEngine document(512, 100000, true); + document.setRenderViewport(1, 0, 49800, 512, 512); + document.setToolWithParams("pen", 10, 0x000000, "pixel"); + document.touchBegan(100, 49900, 1); + for (int y = 49905; y <= 50100; y += 5) document.touchMoved(100, y, 1); + auto livePixels = renderCrop(document, context, 512, 512); + expectInk(livePixels, 100, 150, true, "Continuous live ink lost above boundary"); + expectInk(livePixels, 100, 250, true, "Continuous live ink lost below boundary"); + document.touchEnded(); + for (float scale : {1.0f, 2.0f, 5.0f}) { + document.setRenderViewport(scale, 80, 49950, 100, 100); + auto pixels = renderCrop(document, context, int(100 * scale), int(100 * scale)); + expectInk(pixels, int(20 * scale), int(25 * scale), true, "Committed ink lost above boundary"); + expectInk(pixels, int(20 * scale), int(75 * scale), true, "Committed ink lost below boundary"); + } + if (!document.resizeDocument(512, 101000)) throw std::runtime_error("Continuous page append failed"); + if (document.resizeDocument(0, 101000)) throw std::runtime_error("Invalid document resize accepted"); + const auto bytes = document.serializeDrawing(); + document.undo(); + expectInk(renderCrop(document, context, 500, 500), 100, 125, false, "Undo retained upper fragment"); + expectInk(renderCrop(document, context, 500, 500), 100, 375, false, "Undo retained lower fragment"); + document.redo(); + expectInk(renderCrop(document, context, 500, 500), 100, 375, true, "Redo lost lower fragment"); + SkiaDrawingEngine reloaded(512, 100000, true); + if (!reloaded.deserializeDrawing(bytes)) throw std::runtime_error("Continuous document reload failed"); + reloaded.setRenderViewport(1, 0, 49800, 512, 512); + expectInk(renderCrop(reloaded, context, 512, 512), 100, 250, true, "Reload lost cross-page ink"); + if (!reloaded.selectStrokeAt(100, 50050)) throw std::runtime_error("Cannot select lower half of crossing stroke"); + reloaded.beginSelectionDrag(); + expectInk(renderCrop(reloaded, context, 512, 512), 100, 150, true, "Selection drag lost crossing stroke"); + reloaded.moveSelection(60, 0); + expectInk(renderCrop(reloaded, context, 512, 512), 160, 150, true, "Continuous selection drag lost upper ink"); + expectInk(renderCrop(reloaded, context, 512, 512), 160, 250, true, "Continuous selection drag lost lower ink"); + expectInk(renderCrop(reloaded, context, 512, 512), 100, 250, false, "Continuous selection drag duplicated ink"); + reloaded.finalizeMove(); + reloaded.undo(); + if (!reloaded.selectStrokeAt(100, 50050)) throw std::runtime_error("Move undo did not restore crossing stroke"); + reloaded.endSelectionDrag(); + reloaded.deleteSelection(); + expectInk(renderCrop(reloaded, context, 512, 512), 100, 150, false, "Deleting selected lower half left upper half"); + reloaded.undo(); + expectInk(renderCrop(reloaded, context, 512, 512), 100, 250, true, "Undo selection deletion lost lower half"); + for (int pageTop : {49000, 50000}) { + const auto info = SkImageInfo::MakeN32Premul(512, 1000); + auto surface = SkSurfaces::Raster(info); + surface->getCanvas()->translate(0, -pageTop); + reloaded.renderForExport(surface->getCanvas()); + SkBitmap pixels; + pixels.allocPixels(info); + if (!surface->readPixels(pixels, 0, 0)) throw std::runtime_error("Page export readback failed"); + expectInk(pixels, 100, pageTop == 49000 ? 950 : 50, true, "Paged export cut crossing stroke"); + } + reloaded.setToolWithParams("eraser", 28, 0x000000, "pixel"); + reloaded.touchBegan(85, 50050, 1); + for (int x = 90; x <= 120; x += 5) reloaded.touchMoved(x, 50050, 1); + reloaded.touchEnded(); + expectInk(renderCrop(reloaded, context, 512, 512), 100, 250, false, "Continuous document pixel erase failed"); + } + std::cout << "Zoom surface smoke tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/src/ContinuousEnginePool.tsx b/src/ContinuousEnginePool.tsx index 52795ac..6a47c62 100644 --- a/src/ContinuousEnginePool.tsx +++ b/src/ContinuousEnginePool.tsx @@ -13,6 +13,7 @@ import type { import { getAssignmentKey, } from "./continuous-engine-pool/helpers"; +import { createAssignmentQueue } from "./continuous-engine-pool/assignmentQueue"; import { PooledCanvasSlot } from "./continuous-engine-pool/PooledCanvasSlot"; import type { ContinuousEnginePoolAssignment, @@ -59,6 +60,13 @@ export const ContinuousEnginePool = memo(forwardRef< }, ref) { const slotRefs = useRef>([]); const assignmentKeyRef = useRef(""); + const assignmentQueueRef = useRef(createAssignmentQueue()); + const captureCallbackRef = useRef(onSlotCaptureBeforeUnmount); + captureCallbackRef.current = onSlotCaptureBeforeUnmount; + const captureBeforeReassign = useCallback(async (pageId: string, data: string, previewUri?: string) => { + await captureCallbackRef.current(pageId, data, previewUri); + assignmentQueueRef.current.captured(pageId, data, previewUri); + }, []); const assignedPageIdsRef = useRef>( Array.from({ length: CONTINUOUS_ENGINE_POOL_SIZE }, () => null), ); @@ -108,7 +116,7 @@ export const ContinuousEnginePool = memo(forwardRef< const assignPages = useCallback(async ( assignments: ContinuousEnginePoolAssignment[], - ) => { + ) => assignmentQueueRef.current.enqueue(assignments, async (assignments, isCurrent) => { const nextAssignmentKey = getAssignmentKey(assignments); assignmentKeyRef.current = nextAssignmentKey; const previousSlotByPageId = new Map(); @@ -151,15 +159,12 @@ export const ContinuousEnginePool = memo(forwardRef< placedPageIds.add(assignment.page.id); } - assignedPageIdsRef.current = nextSlotAssignments.map( - (assignment) => assignment?.page.id ?? null, - ); const slotPromises = Array.from( { length: CONTINUOUS_ENGINE_POOL_SIZE }, (_, poolIndex) => { const slotRef = slotRefs.current[poolIndex]; if (!slotRef) { - return Promise.resolve(); + return Promise.reject(new Error(`Ink canvas slot ${poolIndex} is unavailable.`)); } return slotRef.assign({ @@ -172,7 +177,11 @@ export const ContinuousEnginePool = memo(forwardRef< }, ); - await Promise.all(slotPromises); + const results = await Promise.allSettled(slotPromises); + assignedPageIdsRef.current = slotRefs.current.map(slot => slot?.getLoadedPageId() ?? null); + const failure = results.find(result => result.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; + if (!isCurrent()) return; const benchmarkRecordingOptions = benchmarkRecordingOptionsRef.current; if (benchmarkRecordingOptions) { await Promise.all( @@ -187,10 +196,10 @@ export const ContinuousEnginePool = memo(forwardRef< )), ); } - if (assignmentKeyRef.current === nextAssignmentKey) { + if (isCurrent() && assignmentKeyRef.current === nextAssignmentKey) { onAssignmentReadyRef.current?.(nextAssignmentKey); } - }, [ + }), [ backgroundType, canvasHeight, contentPadding, @@ -249,6 +258,7 @@ export const ContinuousEnginePool = memo(forwardRef< }, []); const release = useCallback(async () => { + await assignmentQueueRef.current.drain(); benchmarkRecordingOptionsRef.current = null; benchmarkRecordingSlotIndexesRef.current.clear(); await Promise.all( @@ -296,7 +306,7 @@ export const ContinuousEnginePool = memo(forwardRef< registerRef={registerPerPageSlot} shouldCaptureBeforeReassign={shouldCaptureBeforeReassign} capturePreviewOnReassign={capturePreviewOnReassign} - onCaptureBeforeReassign={onSlotCaptureBeforeUnmount} + onCaptureBeforeReassign={captureBeforeReassign} /> ))} diff --git a/src/InfiniteInkCanvas.tsx b/src/InfiniteInkCanvas.tsx index 69abdbe..ad9730a 100644 --- a/src/InfiniteInkCanvas.tsx +++ b/src/InfiniteInkCanvas.tsx @@ -109,6 +109,7 @@ function InfiniteInkCanvasImpl( showPageLabels = true, overlay, onReady, + onError, onDrawingChange, onSelectionChange, onCurrentPageChange, @@ -175,6 +176,11 @@ function InfiniteInkCanvasImpl( return assignments; }, []); + const reportAssignmentFailure = useCallback((error: unknown) => { + console.error('[InfiniteInkCanvas] Page assignment failed; retained unsaved ink.', error); + onError?.(error instanceof Error ? error : new Error(String(error))); + }, [onError]); + const assignEnginesToPage = useCallback(async (pageIndex: number) => { await enginePoolRef.current?.assignPages(buildAssignmentsForIndex(pageIndex)); }, [buildAssignmentsForIndex]); @@ -205,8 +211,8 @@ function InfiniteInkCanvasImpl( const boundedPageIndex = Math.max(0, Math.min(pageCount - 1, pageIndex)); setVisibleBackgroundPageIndex(boundedPageIndex); setCurrentPage(boundedPageIndex); - void assignEnginesToPage(boundedPageIndex); - }, [assignEnginesToPage, setCurrentPage]); + void assignEnginesToPage(boundedPageIndex).catch(reportAssignmentFailure); + }, [reportAssignmentFailure, assignEnginesToPage, setCurrentPage]); const commitLatestViewportPage = useCallback(() => { const transform = latestTransformRef.current; @@ -425,14 +431,14 @@ function InfiniteInkCanvasImpl( ); setCurrentPage(boundedPageIndex); setVisibleBackgroundPageIndex(boundedPageIndex); - void assignEnginesToPage(boundedPageIndex); + void assignEnginesToPage(boundedPageIndex).catch(reportAssignmentFailure); viewportRef.current?.setTransform({ scale: 1, translateX: 0, translateY: -(boundedPageIndex * pageHeight + contentPadding), animated, }); - }, [assignEnginesToPage, contentPadding, pageHeight, setCurrentPage]); + }, [reportAssignmentFailure, assignEnginesToPage, contentPadding, pageHeight, setCurrentPage]); const revealPagePosition = useCallback((pageIndex: number, pageY: number, animated = true) => { const boundedPageIndex = Math.max( @@ -452,7 +458,7 @@ function InfiniteInkCanvasImpl( const contentY = contentPadding + boundedPageIndex * pageHeight + boundedPageY; setCurrentPage(boundedPageIndex); setVisibleBackgroundPageIndex(boundedPageIndex); - void assignEnginesToPage(boundedPageIndex); + void assignEnginesToPage(boundedPageIndex).catch(reportAssignmentFailure); viewportRef.current?.setTransform({ translateY: getRevealTranslateY( contentY, @@ -462,7 +468,7 @@ function InfiniteInkCanvasImpl( ), animated, }); - }, [assignEnginesToPage, contentPadding, pageHeight, setCurrentPage]); + }, [reportAssignmentFailure, assignEnginesToPage, contentPadding, pageHeight, setCurrentPage]); const addPage = useCallback(async (options?: { force?: boolean; scroll?: boolean }) => { const force = options?.force ?? false; @@ -481,9 +487,10 @@ function InfiniteInkCanvasImpl( const trailingBlankPageIndex = Math.max(0, pagesWithWritableBlank.length - 1); setCurrentPage(trailingBlankPageIndex); if (shouldScroll) scrollToPage(trailingBlankPageIndex, true); - void assignEnginesToPage(trailingBlankPageIndex); + void assignEnginesToPage(trailingBlankPageIndex).catch(reportAssignmentFailure); }, [ assignEnginesToPage, + reportAssignmentFailure, captureDirtyPages, replacePages, scrollToPage, @@ -592,6 +599,7 @@ function InfiniteInkCanvasImpl( }), [ addPage, assignEnginesToPage, + reportAssignmentFailure, captureDirtyPages, getActiveSlot, getCurrentPageSlot, @@ -605,15 +613,15 @@ function InfiniteInkCanvasImpl( useEffect(() => { if (nativeReadyRef.current) { - void assignEnginesToPage(currentPageIndexRef.current); + void assignEnginesToPage(currentPageIndexRef.current).catch(reportAssignmentFailure); } - }, [assignEnginesToPage, pages.length]); + }, [reportAssignmentFailure, assignEnginesToPage, pages.length]); const handleCanvasReady = useCallback(() => { nativeReadyRef.current = true; onReady?.(); - void assignEnginesToPage(currentPageIndexRef.current); - }, [assignEnginesToPage, onReady]); + void assignEnginesToPage(currentPageIndexRef.current).catch(reportAssignmentFailure); + }, [reportAssignmentFailure, assignEnginesToPage, onReady]); return ( diff --git a/src/NativeInkCanvas.tsx b/src/NativeInkCanvas.tsx index 46590de..a47b119 100644 --- a/src/NativeInkCanvas.tsx +++ b/src/NativeInkCanvas.tsx @@ -1,3 +1,5 @@ +import { loadDocument } from './native-ink-canvas/documentBridge'; +import { trimIfNeeded } from './utils/trimIfNeeded'; import React, { useRef, useImperativeHandle, @@ -41,6 +43,7 @@ export { composeContinuousWindow, decomposeContinuousWindow, readBodyFileParsed, + parseNotebookData, } from "./native-ink-canvas/notebookBridge"; export const NativeInkCanvas = forwardRef< @@ -74,6 +77,19 @@ export const NativeInkCanvas = forwardRef< nativeRef.current?.setNativeProps?.(nativeProps); }, + clearDocumentPage: (pageIndex: number, pageHeight: number) => { + if (!Number.isInteger(pageIndex) || pageIndex < 0 || !Number.isFinite(pageHeight) || pageHeight <= 0) { + throw new Error('Invalid document page geometry'); + } + const node = findNodeHandle(nativeRef.current); + if (!node) throw new Error('Document canvas is not mounted'); + if (Platform.OS === 'ios' && MobileInkCanvasViewManager) { + MobileInkCanvasViewManager.clearDocumentPage(node, pageIndex, pageHeight); + } else { + UIManager.dispatchViewManagerCommand(node, 'clearDocumentPage', [pageIndex, pageHeight]); + } + }, + clear: () => { const node = findNodeHandle(nativeRef.current); if (node) { @@ -86,6 +102,19 @@ export const NativeInkCanvas = forwardRef< } }, + recordExternalEdit: async (before: string, after: string): Promise => { + const node = findNodeHandle(nativeRef.current); + if (!node) return false; + if (Platform.OS === 'ios') { + if (!MobileInkCanvasViewManager?.recordExternalEdit) throw new Error('Native overlay history is unavailable'); + return new Promise((resolve) => { + MobileInkCanvasViewManager.recordExternalEdit(node, before, after, resolve); + }); + } + if (!MobileInkModule?.recordExternalEdit) throw new Error('Native overlay history is unavailable'); + return MobileInkModule.recordExternalEdit(node, before, after); + }, + undo: () => { const node = findNodeHandle(nativeRef.current); if (node) { @@ -171,6 +200,12 @@ export const NativeInkCanvas = forwardRef< } }, + loadDocument: async (pagePayloads: string[], pageHeight: number): Promise => { + const node = findNodeHandle(nativeRef.current); + if (!node) throw new Error('View not found'); + lastToolSigRef.current = null; + return loadDocument(node, pagePayloads, pageHeight); + }, loadBase64Data: async (base64String: string): Promise => { const node = findNodeHandle(nativeRef.current); if (!node) { @@ -178,7 +213,7 @@ export const NativeInkCanvas = forwardRef< } lastToolSigRef.current = null; - const trimmedPayload = base64String?.trim() ?? ''; + const trimmedPayload = trimIfNeeded(base64String); if (!trimmedPayload) { if (Platform.OS === 'ios' && MobileInkCanvasViewManager) { MobileInkCanvasViewManager.clear(node); @@ -319,7 +354,7 @@ export const NativeInkCanvas = forwardRef< throw new Error('View not found'); } - const trimmedPayload = base64String?.trim() ?? ''; + const trimmedPayload = trimIfNeeded(base64String); const normalizedPayload = trimmedPayload ? normalizePagePayloadForNativeLoad(trimmedPayload) : { isValid: true, normalizedPayload: '', reasonCode: null }; @@ -575,6 +610,7 @@ export const NativeInkCanvas = forwardRef< pdfBackgroundUri: props.pdfBackgroundUri, renderSuspended: props.renderSuspended, drawingPolicy: props.drawingPolicy, + continuousDocument: props.continuousDocument ?? false, renderScale: props.renderScale ?? 1, renderViewportLeftRatio: props.renderViewportLeftRatio ?? 0, renderViewportTopRatio: props.renderViewportTopRatio ?? 0, diff --git a/src/ZoomableInkViewport.tsx b/src/ZoomableInkViewport.tsx index 9dc4176..0451296 100644 --- a/src/ZoomableInkViewport.tsx +++ b/src/ZoomableInkViewport.tsx @@ -4,8 +4,6 @@ import { GestureDetector } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, - withTiming, - cancelAnimation, } from 'react-native-reanimated'; import type { TouchExclusionRect, @@ -15,6 +13,7 @@ import type { import { shouldLockHorizontalTranslation as shouldLockHorizontalTranslationForLayout, } from './zoomable-ink-viewport/geometry'; +import { useViewportTransition } from './zoomable-ink-viewport/useViewportTransition'; import { useZoomableViewportGestures } from './zoomable-ink-viewport/useZoomableViewportGestures'; export type { @@ -38,8 +37,10 @@ const ZoomableInkViewport = forwardRef { - cancelAnimation(scale); - cancelAnimation(translateX); - cancelAnimation(translateY); - scale.value = 1; - translateX.value = 0; - translateY.value = 0; - savedScale.value = 1; - savedTranslateX.value = 0; - savedTranslateY.value = 0; - }, [scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY]); + const applyTransition = useViewportTransition({ + scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY, + containerWidth, containerHeight, isGesturingRef, isDecayingX, isDecayingY, + isClampingX, isClampingY, isMotionActive, + }, onMotionStateChange, onTransformChange); + const resetZoom = useCallback(() => { + applyTransition({ scale: 1, translateX: 0, translateY: 0 }, false); + }, [applyTransition]); const resetZoomAnimated = useCallback(() => { - cancelAnimation(scale); - cancelAnimation(translateX); - cancelAnimation(translateY); - scale.value = withTiming(1, { duration: 150 }); - translateX.value = withTiming(0, { duration: 150 }); - translateY.value = withTiming(0, { duration: 150 }); - savedScale.value = 1; - savedTranslateX.value = 0; - savedTranslateY.value = 0; - }, [scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY]); + applyTransition({ scale: 1, translateX: 0, translateY: 0 }, true); + }, [applyTransition]); const contentFrameStyle = useMemo(() => ({ height: Math.max(0, contentHeight + contentPadding * 2), @@ -250,9 +242,6 @@ const ZoomableInkViewport = forwardRef { - cancelAnimation(scale); - cancelAnimation(translateX); - cancelAnimation(translateY); const resolvedScale = nextScale ?? scale.value; const shouldLockResolvedX = shouldLockHorizontalTranslationForLayout( resolvedScale, @@ -267,21 +256,11 @@ const ZoomableInkViewport = forwardRef { return { @@ -301,7 +280,7 @@ const ZoomableInkViewport = forwardRef + {children} @@ -313,7 +292,7 @@ const ZoomableInkViewport = forwardRef + { global.requestAnimationFrame = originalRequestAnimationFrame; }); + it("waits for the engine's first layout before reporting its page loaded", async () => { + const slots = new Map(); + const onAssignmentReady = jest.fn(); + mockLoadBase64Data.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + const { poolRef } = renderPool({ + onAssignmentReady, + registerPerPageSlot: (pageId, slot) => { + if (slot) slots.set(pageId, slot); + else slots.delete(pageId); + }, + }); + await assignPages(poolRef, buildAssignments([page(0)], 0)); + expect(mockLoadBase64Data).toHaveBeenCalledTimes(3); + expect(slots.get("page-0")?.isLoaded()).toBe(true); + expect(onAssignmentReady).toHaveBeenCalledTimes(1); + }); + it("keeps three native canvases mounted and reassigns without release", async () => { const pages = [page(0), page(1), page(2), page(3)]; const onSlotCaptureBeforeUnmount = jest.fn(); @@ -348,7 +365,8 @@ describe("ContinuousEnginePool", () => { const [firstSlotProps] = mockNativeCanvasProps.slice(-3); expect(firstSlotProps).toEqual(expect.objectContaining({ renderScale: 2, - renderViewportLeftRatio: 100 / 600, + // A 600pt page is centered at x=-100 inside the 400pt viewport. + renderViewportLeftRatio: 200 / 600, renderViewportTopRatio: 100 / 800, renderViewportWidthRatio: 200 / 600, renderViewportHeightRatio: 200 / 800, @@ -410,6 +428,80 @@ describe("ContinuousEnginePool", () => { expect(mockStopBenchmarkRecording).toHaveBeenCalledTimes(1); }); + it("preserves native read errors instead of manufacturing a blank page", async () => { + const slots = new Map(); + const { poolRef } = renderPool({ registerPerPageSlot: (id, slot) => { if (slot) slots.set(id, slot); } }); + await act(async () => {}); + await assignPages(poolRef, buildAssignments([page(0)], 0)); + const failure = new Error("native read failed"); + mockGetBase64Data.mockRejectedValueOnce(failure); + await expect(slots.get("page-0")!.getBase64Data()).rejects.toBe(failure); + mockGetBase64Data.mockResolvedValueOnce('{"pages":{}}'); + await expect(slots.get("page-0")!.getBase64Data()).resolves.toBe('{"pages":{}}'); + mockGetBase64Data.mockResolvedValueOnce(''); + await expect(slots.get("page-0")!.getBase64Data()).rejects.toThrow('no payload'); + }); + + it("rejects a serialization result when its native slot was reassigned during the read", async () => { + const slots = new Map(); + const { poolRef } = renderPool({ registerPerPageSlot: (id, slot) => { if (slot) slots.set(id, slot); } }); + await act(async () => {}); + await assignPages(poolRef, buildAssignments([page(0)], 0)); + let finishRead!: (value: string) => void; + mockGetBase64Data.mockImplementationOnce(() => new Promise(resolve => { finishRead = resolve; })); + const pendingRead = slots.get("page-0")!.getBase64Data(); + const rejected = expect(pendingRead).rejects.toThrow('assignment changed'); + await assignPages(poolRef, buildAssignments([page(1)], 1)); + finishRead('{"pages":{"0":"old-page-data"}}'); + await rejected; + }); + + it.each(['read rejection', 'empty payload', 'commit rejection'])( + "preserves dirty ink and retries after %s during recycling", async failureMode => { + const slots = new Map(); + const ready = jest.fn(); + const capture = jest.fn(async () => undefined); + const { poolRef, view } = renderPool({ + onAssignmentReady: ready, + shouldCaptureBeforeReassign: () => true, + onSlotCaptureBeforeUnmount: capture, + registerPerPageSlot: (id, slot) => { if (slot) slots.set(id, slot); else slots.delete(id); }, + }); + await assignPages(poolRef, buildAssignments([page(0)], 0)); + const originalSlot = slots.get('page-0'); + if (failureMode === 'read rejection') mockGetBase64Data.mockRejectedValueOnce(new Error('read failed')); + if (failureMode === 'empty payload') mockGetBase64Data.mockResolvedValueOnce(''); + if (failureMode === 'commit rejection') capture.mockRejectedValueOnce(new Error('commit failed')); + await act(async () => { + await expect(poolRef.current!.assignPages(buildAssignments([page(1)], 1))).rejects.toThrow(); + }); + expect(slots.get('page-0')).toBe(originalSlot); + expect(originalSlot?.isLoaded()).toBe(true); + expect(mockLoadBase64Data).toHaveBeenCalledTimes(1); + expect(ready).toHaveBeenCalledTimes(1); + const frame = StyleSheet.flatten(view.getByTestId('continuous-engine-pool-slot-0').props.style); + expect(frame.top).toBe(0); + expect(frame.opacity).toBe(1); + await assignPages(poolRef, buildAssignments([page(1)], 1)); + expect(slots.has('page-0')).toBe(false); + expect(slots.get('page-1')?.isLoaded()).toBe(true); + expect(ready).toHaveBeenCalledTimes(2); + }, + ); + + it("does not report a failed page load as ready and can retry it", async () => { + const ready = jest.fn(); + const { poolRef } = renderPool({ onAssignmentReady: ready }); + mockLoadBase64Data.mockRejectedValueOnce(new Error('invalid payload')); + await act(async () => { + await expect(poolRef.current!.assignPages(buildAssignments([page(0)], 0))).rejects.toThrow('invalid payload'); + }); + expect(ready).not.toHaveBeenCalled(); + await assignPages(poolRef, buildAssignments([page(0)], 0)); + expect(ready).toHaveBeenCalledTimes(1); + expect(mockLoadBase64Data).toHaveBeenCalledTimes(2); + }); + it("starts and stops notebook benchmark recording across the mounted pool", async () => { const pages = [page(0), page(1), page(2)]; const { poolRef } = renderPool(); diff --git a/src/__tests__/PageStack.test.tsx b/src/__tests__/PageStack.test.tsx index 33732aa..900acea 100644 --- a/src/__tests__/PageStack.test.tsx +++ b/src/__tests__/PageStack.test.tsx @@ -58,7 +58,7 @@ describe("PageBackgrounds", () => { ]); }); - it("keeps regular backgrounds fully rendered", () => { + it("bounds regular paper backgrounds around the visible page too", () => { const view = render( { />, ); - expect(view.getAllByTestId("mock-page-background")).toHaveLength(10); + expect(view.getAllByTestId("mock-page-background")).toHaveLength(7); }); }); + +it('renders plain paper without native bitmap-backed background views', () => { + const view = render(); + expect(view.queryAllByTestId('mock-page-background')).toHaveLength(0); +}); diff --git a/src/__tests__/assignmentQueue.test.ts b/src/__tests__/assignmentQueue.test.ts new file mode 100644 index 0000000..7bcf6fe --- /dev/null +++ b/src/__tests__/assignmentQueue.test.ts @@ -0,0 +1,51 @@ +import { createAssignmentQueue } from '../continuous-engine-pool/assignmentQueue'; + +const assignment = (id: string) => [{ page: { id, title: id, rotation: 0, data: 'old' }, pageIndex: 0 }]; + +it('serializes native loads and skips superseded queued windows', async () => { + const queue = createAssignmentQueue(); + let release!: () => void; + const started: string[] = []; + const first = queue.enqueue(assignment('a'), async (_, isCurrent) => { + started.push('a'); + await new Promise(resolve => { release = resolve; }); + expect(isCurrent()).toBe(false); + }); + await Promise.resolve(); + const skipped = queue.enqueue(assignment('b'), async () => { started.push('b'); }); + const last = queue.enqueue(assignment('c'), async () => { started.push('c'); }); + expect(started).toEqual(['a']); + release(); + await Promise.all([first, skipped, last]); + expect(started).toEqual(['a', 'c']); +}); + +it('uses newly captured ink when a return to its page was already queued', async () => { + const queue = createAssignmentQueue(); + let release!: () => void; + const first = queue.enqueue(assignment('b'), async () => { + await new Promise(resolve => { release = resolve; }); + queue.captured('a', 'new ink', 'preview'); + }); + await Promise.resolve(); + const original = assignment('a'); + const last = queue.enqueue(original, async pages => { + expect(pages[0].page.data).toBe('new ink'); + expect(pages[0].page.previewUri).toBe('preview'); + }); + release(); + await Promise.all([first, last]); + expect(original[0].page.data).toBe('old'); +}); + +it('drains active work, cancels queued work, and recovers after errors', async () => { + const queue = createAssignmentQueue(); + await expect(queue.enqueue(assignment('a'), async () => { throw new Error('failed'); })).rejects.toThrow('failed'); + const run = jest.fn(async () => undefined); + await queue.enqueue(assignment('b'), run); + expect(run).toHaveBeenCalledTimes(1); + const canceled = queue.enqueue(assignment('c'), run); + await queue.drain(); + await canceled; + expect(run).toHaveBeenCalledTimes(1); +}); diff --git a/src/__tests__/serialization.test.ts b/src/__tests__/serialization.test.ts index 82bc3c7..6ae5b80 100644 --- a/src/__tests__/serialization.test.ts +++ b/src/__tests__/serialization.test.ts @@ -127,6 +127,7 @@ describe('deserializeNotebookData', () => { expect(result.pages[0].data).toBe('data1'); expect(result.pages[1].data).toBe('data2'); expect(result.pages[2].data).toBe('data3'); + expect(result.pages.every(page => page.pdfPageNumber == null)).toBe(true); }); it('extracts original canvas width', () => { diff --git a/src/continuous-engine-pool/PooledCanvasSlot.tsx b/src/continuous-engine-pool/PooledCanvasSlot.tsx index d4054da..2aab206 100644 --- a/src/continuous-engine-pool/PooledCanvasSlot.tsx +++ b/src/continuous-engine-pool/PooledCanvasSlot.tsx @@ -19,7 +19,7 @@ import { OFFSCREEN_TOP, waitForNextFrame, } from "./helpers"; -import { getVisibleContentRect } from "../utils/viewportTransform"; +import { getPageRenderViewport } from "./renderViewport"; import type { ContinuousEnginePoolAssignment, ContinuousEnginePoolSlotRef, @@ -41,10 +41,6 @@ type SlotFrame = { const PAGE_PREVIEW_CAPTURE_SCALE = 0.5; -const clamp = (value: number, min: number, max: number) => ( - Math.max(min, Math.min(max, value)) -); - const hiddenSlotFrame = (height = 0): SlotFrame => ({ top: OFFSCREEN_TOP, height, @@ -175,35 +171,9 @@ export const PooledCanvasSlot = memo(forwardRef localLeft && localBottom > localTop; - - return { - leftRatio: clamp(localLeft / pageWidth, 0, 1), - topRatio: clamp(localTop / canvasHeight, 0, 1), - widthRatio: clamp(Math.max(1, localRight - localLeft) / pageWidth, 0.001, 1), - heightRatio: clamp(Math.max(1, localBottom - localTop) / canvasHeight, 0.001, 1), - intersectsViewport, - }; + return getPageRenderViewport( + viewportTransform, pageIndex, pageWidth, canvasHeight, contentPadding, + ); }, [ canvasHeight, contentPadding, @@ -215,13 +185,19 @@ export const PooledCanvasSlot = memo(forwardRef( () => ({ getBase64Data: async () => { - if (!canvasRef.current) return BLANK_PAGE_PAYLOAD; - try { - const data = await canvasRef.current.getBase64Data(); - return data || BLANK_PAGE_PAYLOAD; - } catch { - return BLANK_PAGE_PAYLOAD; + const canvas = canvasRef.current; + const pageId = loadedPageIdRef.current; + const token = loadTokenRef.current; + if (!canvas || !pageId || !isLoadedRef.current) { + throw new Error('Cannot serialize an unloaded ink page.'); } + const data = await canvas.getBase64Data(); + if (loadTokenRef.current !== token || loadedPageIdRef.current !== pageId || + !isLoadedRef.current || canvasRef.current !== canvas) { + throw new Error('Ink page assignment changed during serialization.'); + } + if (!data) throw new Error('Native ink serialization returned no payload.'); + return data; }, getPreviewData: async (scale = PAGE_PREVIEW_CAPTURE_SCALE) => { if (!canvasRef.current) return null; @@ -288,23 +264,18 @@ export const PooledCanvasSlot = memo(forwardRef { @@ -370,6 +341,11 @@ export const PooledCanvasSlot = memo(forwardRef((resolve) => { requestAnimationFrame(() => { @@ -490,13 +460,15 @@ export const PooledCanvasSlot = memo(forwardRef { @@ -568,6 +541,7 @@ export const PooledCanvasSlot = memo(forwardRef ({ assign, + getLoadedPageId: () => isLoadedRef.current ? loadedPageIdRef.current : null, applyToolState, startBenchmarkRecording, stopBenchmarkRecording, diff --git a/src/continuous-engine-pool/README.md b/src/continuous-engine-pool/README.md new file mode 100644 index 0000000..3ff260f --- /dev/null +++ b/src/continuous-engine-pool/README.md @@ -0,0 +1,34 @@ +# Continuous engine pool + +The pool keeps three native canvases mounted and reassigns them to nearby pages. +`PooledCanvasSlot` owns loading, previews, native events, and the page-local view. +`helpers` defines assignment keys and retry behavior; `types` defines the host API. + +`renderViewport` converts the viewport's center-origin scale and translation into +page-local native rendering ratios. The page stack is horizontally centered; +its x offset is `(containerWidth - pageWidth) / 2`, including when a page is wider +than the viewport. Vertical coordinates include content padding and page index. +Offscreen slots fall back to base resolution. The visible rectangle only controls +rendering quality; it must not truncate stored drawing data. + +The viewport unit tests cover centered, narrow, panned, and page-straddling views. +The pool integration test checks the ratios delivered to the native canvas. + +Stroke serialization must return a real native payload or reject. The pooled +slot no longer substitutes an empty drawing for a native read failure. It also +rejects a read if the slot's assignment changes before the result arrives, so a +save cannot attribute another page's data to the old page. A successfully read +empty drawing remains valid (including an intentional erase-all). Consumers +must preserve their last good page data and retry/report a failed save; they +must not turn a rejection into an empty drawing. + +Assignments run through a serialized, latest-request queue. Superseded waiting +windows are skipped; an in-flight native load finishes before another starts. +Captures update pending assignments so a quick return cannot reload stale ink. +The slot captures dirty data before hiding, moving, or unregistering its current +page. Empty payloads and failed capture commits reject the assignment and retain +the old loaded slot. Preview failures alone do not invalidate a successful save. +All slot operations settle before the pool updates its actual page-to-slot map. +A failed load never emits whole-pool readiness; the caller receives the error +and may retry. Hosts must handle rejected assignments and must not advance their +settled-range checkpoint on failure. diff --git a/src/continuous-engine-pool/__tests__/renderViewport.test.ts b/src/continuous-engine-pool/__tests__/renderViewport.test.ts new file mode 100644 index 0000000..77ea3a7 --- /dev/null +++ b/src/continuous-engine-pool/__tests__/renderViewport.test.ts @@ -0,0 +1,49 @@ +import { getPageRenderViewport } from "../renderViewport"; + +const transform = { + scale: 2, translateX: 0, translateY: 0, + containerWidth: 1000, containerHeight: 800, +}; + +describe("centered page render viewport", () => { + it("sharpens the centered page region, not the container-relative region", () => { + expect(getPageRenderViewport(transform, 0, 600, 1000, 20)).toEqual({ + leftRatio: 50 / 600, + topRatio: 180 / 1000, + widthRatio: 500 / 600, + heightRatio: 400 / 1000, + intersectsViewport: true, + }); + }); + + it("handles a page wider than its viewport and horizontal panning", () => { + const result = getPageRenderViewport({ + ...transform, containerWidth: 400, translateX: -100, + }, 0, 800, 1000, 0); + // Visible container coordinates [150,350], page starts at -200. + expect(result.leftRatio).toBe(350 / 800); + expect(result.widthRatio).toBe(200 / 800); + }); + + it("projects a zoomed viewport straddling two pages into each native canvas", () => { + const crossing = { ...transform, translateY: -1200 }; + const first = getPageRenderViewport(crossing, 0, 600, 1000, 20); + const second = getPageRenderViewport(crossing, 1, 600, 1000, 20); + expect(first.topRatio).toBe(0.78); + expect(first.heightRatio).toBe(0.22); + expect(second.topRatio).toBe(0); + expect(second.heightRatio).toBe(0.18); + expect(first.intersectsViewport && second.intersectsViewport).toBe(true); + }); + + it("does not allocate sharpened surfaces for offscreen pages", () => { + expect(getPageRenderViewport(transform, 3, 600, 1000, 20).intersectsViewport).toBe(false); + }); + + it("keeps full-page rendering before viewport measurement is available", () => { + expect(getPageRenderViewport(null, null, 600, 1000, 20)).toEqual({ + leftRatio: 0, topRatio: 0, widthRatio: 1, heightRatio: 1, + intersectsViewport: true, + }); + }); +}); diff --git a/src/continuous-engine-pool/assignmentQueue.ts b/src/continuous-engine-pool/assignmentQueue.ts new file mode 100644 index 0000000..2eb95d2 --- /dev/null +++ b/src/continuous-engine-pool/assignmentQueue.ts @@ -0,0 +1,39 @@ +import type { ContinuousEnginePoolAssignment } from './types'; + +/** Native loads cannot overlap: an older load could overwrite a newer page. */ +export function createAssignmentQueue() { + let tail = Promise.resolve(); + let generation = 0; + const pending = new Set(); + return { + enqueue( + assignments: ContinuousEnginePoolAssignment[], + run: (pages: ContinuousEnginePoolAssignment[], isCurrent: () => boolean) => Promise, + ): Promise { + const request = ++generation; + const pages = assignments.map(assignment => ({ ...assignment })); + pending.add(pages); + const task = tail.then(async () => { + if (request === generation) await run(pages, () => request === generation); + }).finally(() => pending.delete(pages)); + // A rejected caller must not poison the queue for the next scroll/retry. + tail = task.catch(() => undefined); + return task; + }, + captured(pageId: string, data: string, previewUri?: string) { + // A queued return to a page must use ink captured while leaving it, + // rather than the stale page object supplied before that capture finished. + for (const pages of pending) { + for (const assignment of pages) { + if (assignment.page.id === pageId) { + assignment.page = { ...assignment.page, data, previewUri }; + } + } + } + }, + async drain() { + ++generation; + await tail; + }, + }; +} diff --git a/src/continuous-engine-pool/renderViewport.ts b/src/continuous-engine-pool/renderViewport.ts new file mode 100644 index 0000000..9d7dc3d --- /dev/null +++ b/src/continuous-engine-pool/renderViewport.ts @@ -0,0 +1,40 @@ +import { getVisibleContentRect } from "../utils/viewportTransform"; +import type { ViewportTransformLike } from "../utils/viewportTransform"; + +const clamp = (value: number, min: number, max: number) => ( + Math.max(min, Math.min(max, value)) +); + +/** Native viewport ratios for a page in the centered continuous page stack. */ +export function getPageRenderViewport( + transform: ViewportTransformLike | null, + pageIndex: number | null, + pageWidth: number, + pageHeight: number, + contentPadding: number, +) { + if (!transform || pageIndex === null || pageWidth <= 0 || pageHeight <= 0) { + return { + leftRatio: 0, topRatio: 0, widthRatio: 1, heightRatio: 1, + intersectsViewport: true, + }; + } + + const visible = getVisibleContentRect(transform); + // Both offsets are in untransformed content coordinates. Subtract them + // after undoing the viewport's center-origin scale and translation. + const pageLeft = (transform.containerWidth - pageWidth) / 2; + const pageTop = contentPadding + pageIndex * pageHeight; + const left = clamp(visible.left - pageLeft, 0, pageWidth); + const right = clamp(visible.right - pageLeft, 0, pageWidth); + const top = clamp(visible.top - pageTop, 0, pageHeight); + const bottom = clamp(visible.bottom - pageTop, 0, pageHeight); + + return { + leftRatio: left / pageWidth, + topRatio: top / pageHeight, + widthRatio: clamp(Math.max(1, right - left) / pageWidth, 0.001, 1), + heightRatio: clamp(Math.max(1, bottom - top) / pageHeight, 0.001, 1), + intersectsViewport: right > left && bottom > top, + }; +} diff --git a/src/continuous-engine-pool/types.ts b/src/continuous-engine-pool/types.ts index 878ad0c..f238ae3 100644 --- a/src/continuous-engine-pool/types.ts +++ b/src/continuous-engine-pool/types.ts @@ -100,7 +100,7 @@ export type ContinuousEnginePoolProps = { pageId: string, data: string, previewUri?: string, - ) => void; + ) => void | Promise; }; export type PooledCanvasSlotAssignOptions = { @@ -113,6 +113,7 @@ export type PooledCanvasSlotAssignOptions = { export type PooledCanvasSlotHandle = { assign: (options: PooledCanvasSlotAssignOptions) => Promise; + getLoadedPageId: () => string | null; applyToolState: (toolState: ContinuousEnginePoolToolState) => void; startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; stopBenchmarkRecording: () => Promise; @@ -157,7 +158,7 @@ export type PooledCanvasSlotProps = { pageId: string, data: string, previewUri?: string, - ) => void; + ) => void | Promise; }; export type NativeCanvasRef = NativeInkCanvasRef; diff --git a/src/index.ts b/src/index.ts index 01719fe..3b189d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { composeContinuousWindow, decomposeContinuousWindow, readBodyFileParsed, + parseNotebookData, } from "./NativeInkCanvas"; export type { NativeInkCanvasProps, @@ -34,6 +35,7 @@ export type { ContinuousEnginePoolToolState, } from "./ContinuousEnginePool"; +export { ContinuousDocumentCanvas, type ContinuousDocumentCanvasRef, type ContinuousDocumentCanvasProps } from "./infinite-ink-canvas/ContinuousDocumentCanvas"; export { InfiniteInkCanvas } from "./InfiniteInkCanvas"; export type { InfiniteInkCanvasProps, @@ -86,3 +88,7 @@ export type { TextBox, ToolType, } from "./types"; + +export { composeDocument, partitionDocument, projectDocumentPages } from "./native-ink-canvas/documentBridge"; + +export { getDocumentRenderViewport } from "./utils/documentViewport"; diff --git a/src/infinite-ink-canvas/ContinuousDocumentCanvas.tsx b/src/infinite-ink-canvas/ContinuousDocumentCanvas.tsx new file mode 100644 index 0000000..dfd2452 --- /dev/null +++ b/src/infinite-ink-canvas/ContinuousDocumentCanvas.tsx @@ -0,0 +1,261 @@ +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { PixelRatio, StyleSheet, View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { NativeInkCanvas, type NativeInkCanvasRef } from '../NativeInkCanvas'; +import ZoomableInkViewport, { type ZoomableInkViewportRef, type TouchExclusionRect } from '../ZoomableInkViewport'; +import { waitForNextFrame } from '../continuous-engine-pool/helpers'; +import { partitionDocument } from '../native-ink-canvas/documentBridge'; +import type { NotebookPage, SerializedNotebookData } from '../types'; +import type { InfiniteInkCanvasProps, InfiniteInkCanvasRef, InfiniteInkViewportTransform } from './types'; +import { DEFAULT_CONTENT_PADDING, DEFAULT_PAGE_HEIGHT, DEFAULT_PAGE_WIDTH } from './constants'; +import { clonePage, getVisiblePageIndex, parseNotebookData } from './notebookPages'; +import { createBlankPage, BLANK_PAGE_PAYLOAD, pageHasContent, pageHasPdfBackground } from '../utils/pageGrowth'; +import { getVisibleContentRect, getRevealTranslateY, getRevealTargetScreenY } from '../utils/viewportTransform'; +import { quantizeNativeRenderScale } from '../utils/nativeRenderScale'; +import { computeDataSignature } from '../utils/dataSignature'; +import { PageBackgrounds, PageBreaks } from './PageStack'; +import { selectionBoundsToTouchRect } from './selectionTouchRects'; +import { styles } from './styles'; + +export type ContinuousDocumentCanvasRef = InfiniteInkCanvasRef & { + recordExternalEdit: (before: string, after: string) => Promise; +}; +export type ContinuousDocumentCanvasProps = InfiniteInkCanvasProps & { + /** Apply this host-owned overlay patch after a native undo/redo. */ + onExternalEdit?: (payload: string) => void; + /** Called after the corresponding ink/overlay update has been delivered. */ + onHistoryAction?: (action: "undo" | "redo") => void; +}; + +// Page payloads retain whole crossing strokes and global drawing order. All +// pages are captured together; committing a dirty subset would corrupt order. +export const ContinuousDocumentCanvas = forwardRef(function ContinuousDocumentCanvas(props, ref) { + const { pageWidth = DEFAULT_PAGE_WIDTH, pageHeight = DEFAULT_PAGE_HEIGHT, + contentPadding = DEFAULT_CONTENT_PADDING, toolState } = props; + const originalWidth = useRef(pageWidth); + const [pages, setPages] = useState(() => { + const initial = parseNotebookData(props.initialData); + originalWidth.current = initial?.originalCanvasWidth ?? pageWidth; + return initial?.pages.length ? initial.pages.map(clonePage) + : Array.from({ length: Math.max(1, props.initialPageCount ?? 2) }, (_, i) => createBlankPage(i)); + }); + const pagesRef = useRef(pages); + const canvas = useRef(null); + const viewport = useRef(null); + const callbacks = useRef(props); callbacks.current = props; + const ready = useRef(false); + const [inputReady, setInputReady] = useState(false); + const initializing = useRef(false); + const mounted = useRef(true); + const currentPage = useRef(0); + const drawingPage = useRef(0); + const revision = useRef(0); + const captured = useRef<{ revision: number; data: SerializedNotebookData } | null>(null); + const appendAfterStroke = useRef(false); + const dirtyPages = useRef(new Set()); + const [visiblePage, setVisiblePage] = useState(0); + const [transform, setTransform] = useState(null); + const [selectionRects, setSelectionRects] = useState([]); + const documentHeight = pages.length * pageHeight; + const density = PixelRatio.get(); + const report = useCallback((error: unknown) => { + const failure = error instanceof Error ? error : new Error(String(error)); + console.error('[ContinuousDocumentCanvas]', failure); + callbacks.current.onError?.(failure); + }, []); + const replacePages = useCallback((next: NotebookPage[]) => { + pagesRef.current = next; + setPages(next); + callbacks.current.onPagesChange?.(next); + }, []); + const applyTool = useCallback(() => { + const tool = callbacks.current.toolState; + canvas.current?.setTool(tool.toolType, tool.width, tool.color, tool.eraserMode); + }, []); + useEffect(() => { if (ready.current) applyTool(); }, [toolState, applyTool]); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; ready.current = false; revision.current++; }; + }, []); + + const load = useCallback(async (data: SerializedNotebookData | string) => { + if (initializing.current) throw new Error('Document load is already in progress'); + const parsed = parseNotebookData(data); + if (!parsed?.pages.length) throw new Error('Cannot load a document without pages'); + const next = parsed.pages.map(clonePage); + initializing.current = true; + ready.current = false; + setInputReady(false); + try { + // Compose before altering the mounted document; malformed data cannot + // replace a good notebook with blank ink. + const pagePayloads = next.map(p => p.data ?? BLANK_PAGE_PAYLOAD); + if (!mounted.current || !canvas.current) throw new Error('Document canvas was unmounted'); + let loaded = false; + for (let attempt = 0; attempt < 90 && !loaded; attempt++) { + if (!mounted.current || !canvas.current) throw new Error('Document canvas was unmounted'); + loaded = await canvas.current.loadDocument(pagePayloads, pageHeight * density); + if (!loaded) await waitForNextFrame(); + } + if (!loaded) throw new Error('Native document load failed'); + if (!mounted.current) throw new Error('Document canvas was unmounted'); + revision.current++; + originalWidth.current = parsed.originalCanvasWidth ?? pageWidth; + dirtyPages.current.clear(); + appendAfterStroke.current = false; + replacePages(next); + captured.current = { revision: revision.current, data: { version: '1.0', pages: next, originalCanvasWidth: originalWidth.current } }; + currentPage.current = Math.min(currentPage.current, next.length - 1); + ready.current = true; + setInputReady(true); + applyTool(); + } catch (error) { + // A native failure may follow partial canvas replacement. Keep input + // and persistence blocked until a confirmed load restores consistency. + ready.current = false; + if (mounted.current) setInputReady(false); + throw error; + } finally { initializing.current = false; } + }, [applyTool, density, pageHeight, pageWidth, replacePages]); + const onReady = useCallback(() => { + if (ready.current || initializing.current) return; + void load({ version: "1.0", pages: pagesRef.current, originalCanvasWidth: originalWidth.current }) + .then(() => callbacks.current.onReady?.()).catch(report); + }, [load, report]); + const capture = useCallback(async (): Promise => { + const native = canvas.current; + if (!ready.current || !native) throw new Error('Document is not ready to save'); + const version = revision.current; + if (captured.current?.revision === version) { + const snapshot = captured.current.data; + return { ...snapshot, pages: snapshot.pages.map(page => ({ ...page })) }; + } + const source = pagesRef.current; + const payload = await native.getBase64Data(); + if (!payload) throw new Error('Native document serialization returned no data'); + const partitions = await partitionDocument(payload, source.length, pageHeight * density); + if (!mounted.current || !ready.current || native !== canvas.current || version !== revision.current || source !== pagesRef.current) + throw new Error('Document changed during capture; retry saving its current state'); + if (partitions.length !== source.length) throw new Error('Incomplete document partition'); + const capturedPages = source.map((page, i) => ({ ...page, data: partitions[i], dataSignature: computeDataSignature(partitions[i]), + previewUri: undefined, previewDataSignature: undefined })); + const data: SerializedNotebookData = { version: '1.0', pages: capturedPages, originalCanvasWidth: originalWidth.current }; + captured.current = { revision: version, data }; + return { ...data, pages: data.pages.map(page => ({ ...page })) }; + }, [density, pageHeight]); + const scrollToPage = useCallback((index: number, animated = true) => { + const bounded = Math.max(0, Math.min(pagesRef.current.length - 1, index)); + currentPage.current = bounded; setVisiblePage(bounded); + callbacks.current.onCurrentPageChange?.(bounded); + viewport.current?.setTransform({ scale: 1, translateX: 0, translateY: -(bounded * pageHeight + contentPadding), animated }); + }, [contentPadding, pageHeight]); + const addPage = useCallback(async (options?: { force?: boolean; scroll?: boolean }) => { + if (!ready.current) throw new Error('Document is not ready to add a page'); + const last = pagesRef.current[pagesRef.current.length - 1]; + if (!options?.force && last && !pageHasContent(last, dirtyPages.current)) { + if (options?.scroll !== false) scrollToPage(pagesRef.current.length - 1); + return; + } + const next = [...pagesRef.current, createBlankPage(pagesRef.current.length)]; + revision.current++; + replacePages(next); + if (options?.scroll !== false) scrollToPage(next.length - 1); + }, [replacePages, scrollToPage]); + useImperativeHandle(ref, () => ({ + getNotebookData: capture, loadNotebookData: load, addPage, + recordExternalEdit: (before, after) => ready.current && canvas.current + ? canvas.current.recordExternalEdit(before, after) : Promise.resolve(false), + undo: () => { if (ready.current) canvas.current?.undo(); }, + redo: () => { if (ready.current) canvas.current?.redo(); }, + clearCurrentPage: () => { if (ready.current) canvas.current?.clearDocumentPage(currentPage.current, pageHeight * density); }, + setTool: tool => canvas.current?.setTool(tool.toolType, tool.width, tool.color, tool.eraserMode), + getCurrentPageIndex: () => currentPage.current, + resetViewport: (animated = true) => animated ? viewport.current?.resetZoomAnimated() : viewport.current?.resetZoom(), + scrollToPage, + revealPagePosition: (index, y, animated = true) => { + const t = viewport.current?.getTransform(); + if (!t) return; + const bounded = Math.max(0, Math.min(pagesRef.current.length - 1, index)); + viewport.current?.setTransform({ translateY: getRevealTranslateY(contentPadding + bounded * pageHeight + y, + getRevealTargetScreenY(t.containerHeight), t.scale, t.containerHeight), animated }); + }, + startBenchmarkRecording: async options => { + if (!canvas.current?.startBenchmarkRecording) throw new Error('Native recorder unavailable'); + return canvas.current.startBenchmarkRecording(options); + }, + stopBenchmarkRecording: async () => { + if (!canvas.current?.stopBenchmarkRecording) throw new Error('Native recorder unavailable'); + return canvas.current.stopBenchmarkRecording(); + }, + }), [addPage, capture, contentPadding, density, load, pageHeight, scrollToPage]); + const onTransform = useCallback((next: InfiniteInkViewportTransform) => { + setTransform(next); + const index = getVisiblePageIndex(next, pageHeight, pagesRef.current.length, contentPadding); + if (index !== currentPage.current) { + currentPage.current = index; setVisiblePage(index); + callbacks.current.onCurrentPageChange?.(index); + } + callbacks.current.onTransformChange?.(next); + }, [contentPadding, pageHeight]); + const rect = transform ? getVisibleContentRect(transform) : { left: 0, top: 0, width: pageWidth, height: pageHeight }; + const left = Math.max(0, rect.left - ((transform?.containerWidth ?? pageWidth) - pageWidth) / 2); + const top = Math.max(0, Math.min(documentHeight - 1, rect.top - contentPadding)); + return + 0} + transformNotificationMinIntervalMs={props.transformNotificationMinIntervalMs ?? 16} + onTransformChange={onTransform} onMotionStateChange={props.onMotionStateChange}> + + + + { + revision.current++; + drawingPage.current = Math.max(0, Math.min(pagesRef.current.length - 1, Math.floor(event.nativeEvent.y / pageHeight))); + const last = pagesRef.current[pagesRef.current.length - 1]; + appendAfterStroke.current = drawingPage.current === pagesRef.current.length - 1 && + !pageHasPdfBackground(last) && ['pen', 'highlighter', 'crayon', 'calligraphy'].includes(callbacks.current.toolState.toolType); + }} + onDrawingChange={event => { + if (!ready.current) return; + const action = event?.nativeEvent.historyAction; + const patch = event?.nativeEvent.externalEdit?.payload; + if (patch !== undefined) { + callbacks.current.onExternalEdit?.(patch); + if (action) callbacks.current.onHistoryAction?.(action); + return; + } + revision.current++; + const page = pagesRef.current[drawingPage.current]; + if (page) dirtyPages.current.add(page.id); + if (appendAfterStroke.current) { + appendAfterStroke.current = false; + replacePages([...pagesRef.current, createBlankPage(pagesRef.current.length)]); + } + if (page) callbacks.current.onDrawingChange?.(page.id); + if (action) callbacks.current.onHistoryAction?.(action); + }} + onSelectionChange={({ nativeEvent: { count, bounds } }) => { + const touchRect = selectionBoundsToTouchRect({ bounds: bounds ?? null, pageIndex: 0, pageWidth, pageHeight, + contentPadding, containerWidth: transform?.containerWidth ?? pageWidth }); + setSelectionRects(count && touchRect ? [touchRect] : []); + const index = bounds ? Math.max(0, Math.min(pagesRef.current.length - 1, Math.floor(bounds.y / pageHeight))) : currentPage.current; + const page = pagesRef.current[index]; + if (page) callbacks.current.onSelectionChange?.(page.id, count, bounds ? { ...bounds, y: bounds.y - index * pageHeight } : null); + }} + onPencilDoubleTap={event => callbacks.current.onPencilDoubleTap?.(event)} /> + {props.overlay} + + + + + ; +}); diff --git a/src/infinite-ink-canvas/PageStack.tsx b/src/infinite-ink-canvas/PageStack.tsx index d3b024d..5030d85 100644 --- a/src/infinite-ink-canvas/PageStack.tsx +++ b/src/infinite-ink-canvas/PageStack.tsx @@ -5,7 +5,7 @@ import type { NotebookPage } from "../types"; import { getContinuousEnginePoolRange } from "../utils/continuousEnginePool"; import { styles } from "./styles"; -const PDF_BACKGROUND_WINDOW_SIZE = 7; +const BACKGROUND_WINDOW_SIZE = 7; export type PageBackgroundsProps = { pages: NotebookPage[]; @@ -26,14 +26,9 @@ export const PageBackgrounds = memo(function PageBackgrounds({ showPageLabels, visiblePageIndex, }: PageBackgroundsProps) { - const shouldWindowPdfBackgrounds = backgroundType === "pdf"; - const renderRange = shouldWindowPdfBackgrounds - ? getContinuousEnginePoolRange( - visiblePageIndex, - pages.length, - PDF_BACKGROUND_WINDOW_SIZE, - ) - : { startIndex: 0, endIndex: pages.length - 1 }; + const renderRange = getContinuousEnginePoolRange( + visiblePageIndex, pages.length, BACKGROUND_WINDOW_SIZE, + ); const renderedPages = []; for ( @@ -49,6 +44,7 @@ export const PageBackgrounds = memo(function PageBackgrounds({ return ( <> + {renderedPages.map(({ page, pageIndex }) => { const pdfBackgroundUri = backgroundType === "pdf" && pdfBackgroundBaseUri ? `${pdfBackgroundBaseUri}#page=${page.pdfPageNumber || pageIndex + 1}` @@ -67,11 +63,13 @@ export const PageBackgrounds = memo(function PageBackgrounds({ }, ]} > - + {backgroundType !== 'plain' && ( + + )} {showPageLabels ? ( {pageIndex + 1} ) : null} @@ -96,6 +94,8 @@ export const PageBreaks = memo(function PageBreaks({ {pages.slice(1).map((page, pageIndex) => ( ['first', 'second']); +const mockLoad = jest.fn(async (_payloads: string[], _height: number) => true); +const mockCapture = jest.fn(async () => 'document'); +const mockClear = jest.fn(); +const mockRecordExternal = jest.fn(async () => true); +let mockProps: any; +jest.mock('../../native-ink-canvas/documentBridge', () => ({ + partitionDocument: (...args: unknown[]) => (mockPartition as any)(...args), +})); +jest.mock('react-native-gesture-handler', () => ({ GestureHandlerRootView: require('react-native').View })); +jest.mock('../PageStack', () => ({ PageBackgrounds: () => null, PageBreaks: () => null })); +jest.mock('../../ZoomableInkViewport', () => ({ __esModule: true, + default: require('react').forwardRef((props: any, ref: any) => { + require('react').useImperativeHandle(ref, () => ({ setTransform: jest.fn() })); + return <>{props.children}; + }), +})); +jest.mock('../../NativeInkCanvas', () => ({ + NativeInkCanvas: require('react').forwardRef((props: any, ref: any) => { + mockProps = props; + require('react').useImperativeHandle(ref, () => ({ loadDocument: mockLoad, getBase64Data: mockCapture, + setTool: jest.fn(), clearDocumentPage: mockClear, recordExternalEdit: mockRecordExternal })); + require('react').useEffect(() => { props.onCanvasReady(); }, [props.onCanvasReady]); + return null; + }), +})); +const data = { version: '1.0' as const, originalCanvasWidth: 600, pages: [ + { id: 'a', title: 'A', rotation: 0, data: '{"pages":{}}' }, + { id: 'b', title: 'B', rotation: 0, data: '{"pages":{}}' }, +] }; +async function setup() { + const ref = React.createRef(); + const changed = jest.fn(); + const tree = render(); + await act(async () => {}); + return { ref, changed, tree: tree! }; +} +beforeEach(() => { jest.clearAllMocks(); }); +it('retains blank pages and captures the whole document in order', async () => { + const { ref, tree } = await setup(); + expect(mockLoad.mock.calls[0][0]).toHaveLength(2); + act(() => mockProps.onDrawingChange()); + const result = await ref.current!.getNotebookData(); + expect(result.pages.map(p => p.data)).toEqual(['first', 'second']); + expect(result.originalCanvasWidth).toBe(600); + tree.unmount(); +}); +it('rejects a save whose document changed during native capture', async () => { + const { ref, tree } = await setup(); + let resolve!: (value: string) => void; + mockCapture.mockImplementationOnce(() => new Promise(done => { resolve = done; })); + act(() => mockProps.onDrawingChange()); + const save = ref.current!.getNotebookData(); + mockProps.onDrawingChange(); + resolve('document'); + await expect(save).rejects.toThrow('Document changed'); + tree.unmount(); +}); +it('uses point coordinates for the drawing owner and native coordinates for page clear', async () => { + const { ref, changed, tree } = await setup(); + act(() => { + mockProps.onDrawingBegin({ nativeEvent: { x: 10, y: 1100 } }); + mockProps.onDrawingChange(); + }); + expect(changed).toHaveBeenCalledWith('b'); + act(() => ref.current!.scrollToPage(1)); + ref.current!.clearCurrentPage(); + expect(mockClear.mock.calls[0][0]).toBe(1); + expect(mockClear.mock.calls[0][1]).toBe(1000 * require('react-native').PixelRatio.get()); + tree.unmount(); +}); + +it('waits for native layout instead of abandoning the first document load', async () => { + mockLoad.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const ref = React.createRef(); + const loaded = jest.fn(); + const tree = render(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 70)); }); + expect(loaded).toHaveBeenCalledTimes(1); + expect(mockLoad).toHaveBeenCalledTimes(2); + tree.unmount(); +}); + +it('adds one trailing blank page after writing on the last page without growing on undo notifications', async () => { + const { ref, tree } = await setup(); + act(() => { + mockProps.onDrawingBegin({ nativeEvent: { x: 10, y: 1100 } }); + mockProps.onDrawingChange(); + mockProps.onDrawingChange(); + }); + mockPartition.mockResolvedValueOnce(['first', 'second', '{"pages":{}}']); + const saved = await ref.current!.getNotebookData(); + expect(saved.pages).toHaveLength(3); + expect(saved.pages[0].id).toBe('a'); + expect(saved.pages[1].id).toBe('b'); + tree.unmount(); +}); + +it('blocks drawing and overlays until document replacement completes', async () => { + const { ref, tree } = await setup(); + let finish!: (value: boolean) => void; + mockLoad.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + let load!: Promise; + act(() => { load = ref.current!.loadNotebookData(data); }); + expect(tree.getByTestId('continuous-document-input').props.pointerEvents).toBe('none'); + await act(async () => { finish(true); await load; }); + expect(tree.getByTestId('continuous-document-input').props.pointerEvents).toBe('auto'); + tree.unmount(); +}); + +it('does not serialize unchanged ink on repeated saves, but invalidates after editing', async () => { + const { ref, tree } = await setup(); + await ref.current!.getNotebookData(); + await ref.current!.getNotebookData(); + expect(mockCapture).not.toHaveBeenCalled(); + act(() => mockProps.onDrawingChange()); + const saved = await ref.current!.getNotebookData(); + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(await ref.current!.getNotebookData()).toEqual(saved); + expect(mockCapture).toHaveBeenCalledTimes(1); + act(() => mockProps.onDrawingBegin({ nativeEvent: { x: 2, y: 5 } })); + await ref.current!.getNotebookData(); + expect(mockCapture).toHaveBeenCalledTimes(2); + tree.unmount(); +}); + +it('routes external undo patches without dirtying ink or appending a page', async () => { + const ref = React.createRef(); + const external = jest.fn(); + const changed = jest.fn(); + const history = jest.fn(); + const tree = render(); + await act(async () => {}); + await expect(ref.current!.recordExternalEdit('before', 'after')).resolves.toBe(true); + expect(mockRecordExternal).toHaveBeenCalledWith('before', 'after'); + act(() => mockProps.onDrawingChange({ nativeEvent: { historyAction: "undo", externalEdit: { payload: 'before' } } })); + expect(external).toHaveBeenCalledWith('before'); + expect(history).toHaveBeenCalledWith('undo'); + expect(history.mock.invocationCallOrder[0]).toBeGreaterThan(external.mock.invocationCallOrder[0]); + expect(changed).not.toHaveBeenCalled(); + expect((await ref.current!.getNotebookData()).pages).toHaveLength(2); + expect(mockCapture).not.toHaveBeenCalled(); + tree.unmount(); +}); + +it('keeps persistence blocked after a failed replacement until a successful retry', async () => { + const {ref, tree} = await setup(); + mockLoad.mockRejectedValueOnce(new Error('native load failed')); + await act(async () => { + await expect(ref.current!.loadNotebookData(data)).rejects.toThrow('native load failed'); + }); + expect(tree.getByTestId('continuous-document-input').props.pointerEvents).toBe('none'); + await expect(ref.current!.getNotebookData()).rejects.toThrow('not ready'); + await act(async () => { await ref.current!.loadNotebookData(data); }); + expect(tree.getByTestId('continuous-document-input').props.pointerEvents).toBe('auto'); + await expect(ref.current!.getNotebookData()).resolves.toMatchObject({pages: expect.any(Array)}); + tree.unmount(); +}); + +it('does not reparse the initial notebook when tools or viewport state rerender it', async () => { + const raw = JSON.stringify(data); + const parse = jest.spyOn(JSON, 'parse'); + const tool = {toolType: 'pen' as const, width: 2, color: '#000000', eraserMode: 'pixel' as const}; + const tree = render(); + await act(async () => {}); + const initialCalls = parse.mock.calls.filter(([input]) => input === raw).length; + expect(initialCalls).toBeGreaterThan(0); + for (const width of [3, 4, 5]) { + tree.rerender(); + await act(async () => {}); + } + expect(parse.mock.calls.filter(([input]) => input === raw)).toHaveLength(initialCalls); + tree.unmount(); + parse.mockRestore(); +}); diff --git a/src/infinite-ink-canvas/types.ts b/src/infinite-ink-canvas/types.ts index 6a01276..0c9b0dc 100644 --- a/src/infinite-ink-canvas/types.ts +++ b/src/infinite-ink-canvas/types.ts @@ -62,6 +62,8 @@ export type InfiniteInkCanvasProps = { */ overlay?: ReactNode; onReady?: () => void; + /** A page could not be safely saved/reassigned. Dirty ink remains in its slot. */ + onError?: (error: Error) => void; onDrawingChange?: (pageId: string) => void; onSelectionChange?: ( pageId: string, diff --git a/src/native-ink-canvas/README.md b/src/native-ink-canvas/README.md new file mode 100644 index 0000000..feb06d3 --- /dev/null +++ b/src/native-ink-canvas/README.md @@ -0,0 +1,25 @@ +# Native ink bridge + +NativeInkCanvas wraps platform drawing views and notebook serialization bridges. +`recordExternalEdit(before, after)` places opaque host-owned overlay patches in +the same bounded native history as strokes. It resolves false if the engine is +unavailable, the edit is unchanged, or a stroke is unfinished; callers must +retain/restore their prior state on rejection. An undo/redo external edit emits +`onDrawingChange.nativeEvent.externalEdit.payload` with the appropriate patch. +Hosts apply only their own overlay schema, without replacing native ink. + +External patches are session history, never included in ink serialization. They +follow the same capacity, redo branching and load-reset behavior as ink edits. +Native recording does not emit a drawing change (the host already owns the edit). + +Undo/redo events also carry `historyAction: "undo" | "redo"`, including when the +history stack is empty. Hosts can wait for this event to serialize subsequent +asset edits and saves. ContinuousDocumentCanvas forwards it as `onHistoryAction` +after its ink-change or external-overlay callback has run. + +`NativeInkCanvasRef.loadDocument` composes page-owned ink and loads the native +canvas without returning a combined drawing through JavaScript. Both platforms +use the existing confirmed canvas-load result. Malformed composition rejects +before canvas mutation; detached views fail instead of claiming hydration. + +`parseNotebookData` parses the exact selected JSON payload on a native worker. Unlike `readBodyFileParsed`, it does not read disk or change which recovery candidate is loaded. It returns null only when unavailable; parse failures reject so callers can report or use their validated fallback. diff --git a/src/native-ink-canvas/__tests__/notebookParsing.test.ts b/src/native-ink-canvas/__tests__/notebookParsing.test.ts new file mode 100644 index 0000000..c6e77c3 --- /dev/null +++ b/src/native-ink-canvas/__tests__/notebookParsing.test.ts @@ -0,0 +1,27 @@ +import {Platform} from 'react-native'; +import {parseNotebookData} from '../notebookBridge'; +import {MobileInkBridge, MobileInkModule} from '../nativeModules'; +jest.mock('../nativeModules', () => ({ + MobileInkBridge: {parseNotebookData: jest.fn()}, + MobileInkModule: {parseNotebookData: jest.fn()}, +})); +const originalOS = Platform.OS; +afterEach(() => {Platform.OS = originalOS; jest.resetAllMocks();}); +for (const os of ['ios', 'android'] as const) { + test(`${os} parses exactly the selected payload and propagates native failure`, async () => { + Platform.OS = os; + const method = os === 'ios' ? MobileInkBridge.parseNotebookData : MobileInkModule.parseNotebookData; + const data = '{"version":"1.0","pages":[]}'; + method.mockResolvedValueOnce({version: '1.0', pages: []}); + expect(await parseNotebookData(data)).toEqual({version: '1.0', pages: []}); + expect(method).toHaveBeenCalledWith(data); + method.mockRejectedValueOnce(new Error('invalid JSON')); + await expect(parseNotebookData('broken')).rejects.toThrow('invalid JSON'); + method.mockResolvedValueOnce([]); + await expect(parseNotebookData('[]')).rejects.toThrow('invalid root'); + }); +} +test('unavailable platform returns null for the caller fallback', async () => { + Platform.OS = 'web'; + expect(await parseNotebookData('{}')).toBeNull(); +}); diff --git a/src/native-ink-canvas/documentBridge.ts b/src/native-ink-canvas/documentBridge.ts new file mode 100644 index 0000000..59c617e --- /dev/null +++ b/src/native-ink-canvas/documentBridge.ts @@ -0,0 +1,29 @@ +import { NativeModules } from 'react-native'; + +interface DocumentBridge { + loadDocument(viewTag: number, payloads: string[], pageHeight: number): Promise; + projectDocumentPages(payloads: string[], pageHeight: number, pageIndices: number[]): Promise; + composeDocument(payloads: string[], pageHeight: number): Promise; + partitionDocument(payload: string, pageCount: number, pageHeight: number): Promise; +} +function bridge(): DocumentBridge { + const native = NativeModules.MobileInkDocumentBridge as DocumentBridge | undefined; + if (!native) throw new Error('MobileInkDocumentBridge is unavailable. Rebuild the app with the continuous document engine.'); + return native; +} +export function composeDocument(payloads: string[], pageHeight: number): Promise { + return bridge().composeDocument(payloads, pageHeight); +} +export function partitionDocument(payload: string, pageCount: number, pageHeight: number): Promise { + return bridge().partitionDocument(payload, pageCount, pageHeight); +} + +/** Render-only payloads containing neighboring overflow. Do not save these. */ +export function projectDocumentPages(payloads: string[], pageHeight: number, pageIndices: number[]): Promise { + return bridge().projectDocumentPages(payloads, pageHeight, pageIndices); +} + +/** Compose and load without returning the combined document through JavaScript. */ +export function loadDocument(viewTag: number, payloads: string[], pageHeight: number): Promise { + return bridge().loadDocument(viewTag, payloads, pageHeight); +} diff --git a/src/native-ink-canvas/notebookBridge.ts b/src/native-ink-canvas/notebookBridge.ts index e81d865..2d8cf37 100644 --- a/src/native-ink-canvas/notebookBridge.ts +++ b/src/native-ink-canvas/notebookBridge.ts @@ -163,3 +163,16 @@ export async function decomposeContinuousWindow( return MobileInkBridge.decomposeContinuousWindow(windowPayload, pageCount, pageHeight); } + +/** Parse the caller-selected notebook, never a potentially older file on disk. + * Null means the native parser is unavailable; malformed input rejects. */ +export async function parseNotebookData(data: string): Promise | null> { + const native = Platform.OS === "ios" ? MobileInkBridge + : Platform.OS === "android" ? MobileInkModule : undefined; + if (!native?.parseNotebookData) return null; + const result = await native.parseNotebookData(data); + if (!result || typeof result !== "object" || Array.isArray(result)) { + throw new Error("Native notebook parser returned an invalid root"); + } + return result as Record; +} diff --git a/src/native-ink-canvas/types.ts b/src/native-ink-canvas/types.ts index 755ca7a..3a98b72 100644 --- a/src/native-ink-canvas/types.ts +++ b/src/native-ink-canvas/types.ts @@ -9,7 +9,7 @@ import type { NativeSelectionBounds } from "../types"; export interface NativeInkCanvasProps { style?: ViewStyle; - onDrawingChange?: () => void; + onDrawingChange?: (event?: NativeSyntheticEvent<{ historyAction?: "undo" | "redo"; externalEdit?: { payload: string } }>) => void; onDrawingBegin?: (event: NativeSyntheticEvent<{ x: number; y: number }>) => void; onSelectionChange?: (event: { nativeEvent: { count: number; bounds?: NativeSelectionBounds | null } }) => void; onCanvasReady?: () => void; @@ -20,6 +20,8 @@ export interface NativeInkCanvasProps { renderBackend?: NativeInkRenderBackend; /** Native raster scale bucket used to keep ink sharp after zoom settles. */ renderScale?: number; + /** Continuous document mode; fixed for the lifetime of this canvas. */ + continuousDocument?: boolean; /** Visible page rect, expressed as normalized page-space ratios. */ renderViewportLeftRatio?: number; renderViewportTopRatio?: number; @@ -34,11 +36,16 @@ export interface NativeInkCanvasProps { export interface NativeInkCanvasRef { setNativeProps?: (nativeProps: Record) => void; clear: () => void; + /** Continuous document only; pageHeight is in native drawing coordinates. */ + clearDocumentPage: (pageIndex: number, pageHeight: number) => void; + /** Records an opaque overlay patch in the same bounded history as ink. */ + recordExternalEdit: (before: string, after: string) => Promise; undo: () => void; redo: () => void; setTool: (toolType: string, width: number, color: string, eraserMode?: string) => void; getBase64Data: () => Promise; loadBase64Data: (base64String: string) => Promise; + loadDocument: (pagePayloads: string[], pageHeight: number) => Promise; /** * Eagerly release the heavy native state (~13 MB pixel buffer + the * C++ drawing engine + queued JS callbacks) without waiting for ARC. diff --git a/src/payload.ts b/src/payload.ts index 703d45f..d3706f0 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,3 +1,4 @@ +import { trimIfNeeded } from './utils/trimIfNeeded'; const BLANK_PAGE_PAYLOAD = '{"pages":{}}'; export type NormalizedPagePayload = { @@ -9,7 +10,7 @@ export type NormalizedPagePayload = { export function normalizePagePayloadForNativeLoad( pagePayload: string | null | undefined, ): NormalizedPagePayload { - const trimmedPayload = pagePayload?.trim() ?? ""; + const trimmedPayload = trimIfNeeded(pagePayload); if (!trimmedPayload) { return { isValid: true, diff --git a/src/utils/README.md b/src/utils/README.md new file mode 100644 index 0000000..3fbc1bf --- /dev/null +++ b/src/utils/README.md @@ -0,0 +1,9 @@ +# Canvas utilities + +Pure helpers for viewport coordinates, engine-pool ranges, render-scale limits, +page growth, data signatures, and notebook serialization. + +`trimIfNeeded` preserves JavaScript whitespace semantics without calling `trim` +on already-clean strings. Hermes can allocate a complete copy of large notebook +or page strings even when trimming removes nothing. Native load validation and +page-growth checks use this helper without weakening payload validation. diff --git a/src/utils/__tests__/documentViewport.test.ts b/src/utils/__tests__/documentViewport.test.ts new file mode 100644 index 0000000..8110f25 --- /dev/null +++ b/src/utils/__tests__/documentViewport.test.ts @@ -0,0 +1,26 @@ +import { getDocumentRenderViewport } from '../documentViewport'; + +describe('continuous document viewport', () => { + it('starts with one page rather than allocating the full notebook', () => { + expect(getDocumentRenderViewport(null, 800, 1000, 100, 16)).toEqual({ + leftRatio: 0, topRatio: 0, widthRatio: 1, heightRatio: 0.01, + }); + }); + it('accounts for centered pages, padding, and zoom around the viewport center', () => { + const result = getDocumentRenderViewport({scale: 2, translateX: -100, translateY: -2000, + containerWidth: 1000, containerHeight: 800}, 800, 1000, 100, 16); + expect(result.leftRatio).toBeCloseTo(200 / 800); + expect(result.topRatio).toBeCloseTo(1184 / 100000); + expect(result.widthRatio).toBeCloseTo(500 / 800); + expect(result.heightRatio).toBeCloseTo(400 / 100000); + }); + it('keeps overscroll and last-page crops inside document bounds', () => { + for (const translateY of [-1e6, 1e6]) { + const result = getDocumentRenderViewport({scale: 1, translateX: 0, translateY, + containerWidth: 1000, containerHeight: 800}, 800, 1000, 2, 16); + expect(result.topRatio).toBeGreaterThanOrEqual(0); + expect(result.topRatio + result.heightRatio).toBeLessThanOrEqual(1); + expect(result.heightRatio).toBeGreaterThan(0); + } + }); +}); diff --git a/src/utils/__tests__/trimIfNeeded.test.ts b/src/utils/__tests__/trimIfNeeded.test.ts new file mode 100644 index 0000000..8611169 --- /dev/null +++ b/src/utils/__tests__/trimIfNeeded.test.ts @@ -0,0 +1,21 @@ +import { trimIfNeeded } from '../trimIfNeeded'; + +describe('trimIfNeeded', () => { + it.each([undefined, null, '', ' ', '\t\n', '\uFEFF', '\u00a0', '\u2003'])('handles empty/whitespace input %p', value => { + expect(trimIfNeeded(value)).toBe(''); + }); + + it.each([' plain ', '\tplain', 'plain\n', '\uFEFFplain\u00a0', '\u2003plain\u2029', 'a\n b', '\u200bplain\u200b'])('preserves trim semantics for %p', value => { + expect(trimIfNeeded(value)).toBe(value.trim()); + }); + + it('does not call trim for a large already-trimmed notebook', () => { + const data = JSON.stringify({ pages: [{ data: 'a'.repeat(2_000_000), title: 'Math — notes' }] }); + const trim = jest.spyOn(String.prototype, 'trim'); + try { + const result = trimIfNeeded(data); + expect(trim).not.toHaveBeenCalled(); + expect(result).toBe(data); + } finally { trim.mockRestore(); } + }); +}); diff --git a/src/utils/documentViewport.ts b/src/utils/documentViewport.ts new file mode 100644 index 0000000..1c5b2d0 --- /dev/null +++ b/src/utils/documentViewport.ts @@ -0,0 +1,26 @@ +import { getVisibleContentRect, type ViewportTransformLike } from './viewportTransform'; + +/** Ratios into a whole document, bounded to the visible part of the canvas. */ +export function getDocumentRenderViewport( + transform: ViewportTransformLike | null, + pageWidth: number, + pageHeight: number, + pageCount: number, + contentPadding: number, +) { + const width = Math.max(1, pageWidth); + const height = Math.max(1, pageHeight * Math.max(1, pageCount)); + const visible = transform ? getVisibleContentRect(transform) + : { left: 0, top: contentPadding, width, height: pageHeight }; + const pageLeft = ((transform?.containerWidth ?? width) - width) / 2; + const left = Math.max(0, Math.min(width - 1, visible.left - pageLeft)); + const top = Math.max(0, Math.min(height - 1, visible.top - contentPadding)); + const right = Math.min(width, visible.left + visible.width - pageLeft); + const bottom = Math.min(height, visible.top + visible.height - contentPadding); + return { + leftRatio: left / width, + topRatio: top / height, + widthRatio: Math.max(1, right - left) / width, + heightRatio: Math.max(1, bottom - top) / height, + }; +} diff --git a/src/utils/pageGrowth.ts b/src/utils/pageGrowth.ts index 5daf7c5..1e5887d 100644 --- a/src/utils/pageGrowth.ts +++ b/src/utils/pageGrowth.ts @@ -1,3 +1,4 @@ +import { trimIfNeeded } from './trimIfNeeded'; import type { NotebookPage } from "../types"; export const BLANK_PAGE_PAYLOAD = '{"pages":{}}'; @@ -12,11 +13,11 @@ export const createBlankPage = (pageIndex: number): NotebookPage => ({ }); export const isBlankPagePayload = (data?: string) => { - if (!data || data.trim().length === 0) { + if (!data || trimIfNeeded(data).length === 0) { return true; } - return data.trim() === BLANK_PAGE_PAYLOAD; + return trimIfNeeded(data) === BLANK_PAGE_PAYLOAD; }; export const pageHasPdfBackground = (page: NotebookPage) => ( diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index 200a40a..6cbcc2e 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -1,3 +1,4 @@ +import { trimIfNeeded } from './trimIfNeeded'; import { NotebookPage, SerializedNotebookData, TextBox } from '../types'; import { computeDataSignature } from './dataSignature'; @@ -67,13 +68,9 @@ export const buildNotebookFromParsed = ( data: SerializedNotebookData | null | undefined, ): DeserializedNotebookData => { if (data && data.version === "1.0" && Array.isArray(data.pages)) { - // Migration: If pages don't have pdfPageNumber but there are multiple pages, - // assume it's a PDF and assign sequential page numbers - const pages = data.pages.map((page, index) => { + // Only an actual PDF source establishes PDF backing; multiple pages do not. + const pages = data.pages.map((page) => { let next = page; - if (page.pdfPageNumber === undefined && data.pages.length > 1) { - next = { ...next, pdfPageNumber: index + 1 }; - } // Seed dataSignature so the preview cache can compare across the // session even after the page's data is evicted to disk. if (!next.dataSignature && next.data) { @@ -111,7 +108,7 @@ export const reconcilePdfNotebookPageCount = ( detectedPageCount: number, ): PdfNotebookReconciliationResult => { const safePageCount = Math.max(1, Math.floor(detectedPageCount) || 1); - const trimmedNotebookData = existingNotebookData?.trim() ?? ''; + const trimmedNotebookData = trimIfNeeded(existingNotebookData); if (!trimmedNotebookData) { const pages = createBlankPdfPages(0, safePageCount); diff --git a/src/utils/trimIfNeeded.ts b/src/utils/trimIfNeeded.ts new file mode 100644 index 0000000..5d62f62 --- /dev/null +++ b/src/utils/trimIfNeeded.ts @@ -0,0 +1,12 @@ +const WHITESPACE = /\s/u; + +/** + * Hermes can copy an external string even when trim removes nothing. Notebook + * bodies are often tens of MB; inspect their boundaries before asking for a copy. + * Use JavaScript's whitespace class to preserve trim's Unicode/BOM semantics. + */ +export function trimIfNeeded(value: string | null | undefined): string { + if (!value) return ''; + if (!WHITESPACE.test(value[0]) && !WHITESPACE.test(value[value.length - 1])) return value; + return value.trim(); +} diff --git a/src/zoomable-ink-viewport/README.md b/src/zoomable-ink-viewport/README.md new file mode 100644 index 0000000..01f7980 --- /dev/null +++ b/src/zoomable-ink-viewport/README.md @@ -0,0 +1,24 @@ +# Shared notebook viewport + +`ZoomableInkViewport` owns viewport transforms and delegates gestures to +`useZoomableViewportGestures`. Both continuous and paged notebook consumers can +use the same bounds, touch exclusions, and viewport notifications. + +`initialScale` sets only the mount-time scale, clamped to the configured limits. +Its default remains 1, and reset methods return to 1. This supports apps that +open new notes at a different zoom without changing existing consumers. + +`useViewportTransition` handles imperative page jumps and resets. It announces +movement before changing the transform, waits for all animated axes, flushes +the destination, then announces that movement ended. Page pools use that final +notification to load the destination and release their interaction lock. +Superseded or unmounted transitions cannot announce a stale destination; an +intervening gesture remains responsible for its own motion notifications. + +The optional `testID` (default `ink-viewport`) identifies the untransformed, +clipped gesture surface. UI tests should pinch this element rather than a parent +screen that also contains sibling toolbars. + +`viewportCallbacks` keeps the functions passed to `runOnJS` stable and outside render closures. Committed callbacks are replaced in a layout effect and cleared on unmount, so native animation references cannot retain closed notebook contents or call an unmounted consumer. + +Tap selection and gesture exclusion share the inverse of the centered viewport transform, including zoom and pan offsets. diff --git a/src/zoomable-ink-viewport/__tests__/tapCoordinates.test.ts b/src/zoomable-ink-viewport/__tests__/tapCoordinates.test.ts new file mode 100644 index 0000000..8922b4f --- /dev/null +++ b/src/zoomable-ink-viewport/__tests__/tapCoordinates.test.ts @@ -0,0 +1,14 @@ +import {viewportPointToContent} from '../geometry'; + +describe.each([1, 2, 5])('viewport touch routing at %sx', scale => { + it.each([[1024, 1366], [1366, 1024]])('inverts centered zoom and pan in a %s by %s viewport', (width, height) => { + const tx = -180, ty = -870; + for (const content of [{x: 0, y: 0}, {x: 370, y: 1040}, {x: 730, y: 2100}]) { + const screenX = width / 2 + tx + scale * (content.x - width / 2); + const screenY = height / 2 + ty + scale * (content.y - height / 2); + const actual = viewportPointToContent(screenX, screenY, scale, tx, ty, width, height); + expect(actual.x).toBeCloseTo(content.x); + expect(actual.y).toBeCloseTo(content.y); + } + }); +}); diff --git a/src/zoomable-ink-viewport/__tests__/transitionCompletion.test.ts b/src/zoomable-ink-viewport/__tests__/transitionCompletion.test.ts new file mode 100644 index 0000000..9f2cf0b --- /dev/null +++ b/src/zoomable-ink-viewport/__tests__/transitionCompletion.test.ts @@ -0,0 +1,37 @@ +import {createTransitionCompletion} from '../transitionCompletion'; + +test('finishes once after all axes and ignores stale native callbacks', () => { + const bridge = createTransitionCompletion(); + const old = jest.fn(), latest = jest.fn(); + const native = bridge.completeAxis; + bridge.begin(1, 3, old); + native(1); + bridge.begin(2, 3, latest); + native(1); native(1); + native(2); native(2); + expect(latest).not.toHaveBeenCalled(); + native(2); native(2); + expect(latest).toHaveBeenCalledTimes(1); + expect(old).not.toHaveBeenCalled(); +}); +test('clears pending consumers on disposal and before reentrant completion', () => { + const bridge = createTransitionCompletion(); + const disposed = jest.fn(); + bridge.begin(1, 3, disposed); + bridge.clear(); + bridge.completeAxis(1); bridge.completeAxis(1); bridge.completeAxis(1); + expect(disposed).not.toHaveBeenCalled(); + const next = jest.fn(); + bridge.begin(2, 1, () => bridge.begin(3, 1, next)); + bridge.completeAxis(2); bridge.completeAxis(3); + expect(next).toHaveBeenCalledTimes(1); +}); + +test('a cancelled axis releases the transition without publishing its destination', () => { + const bridge = createTransitionCompletion(); + const finish = jest.fn(); + bridge.begin(1, 3, finish); + bridge.completeAxis(1, false); + bridge.completeAxis(1); bridge.completeAxis(1); bridge.completeAxis(1); + expect(finish).not.toHaveBeenCalled(); +}); diff --git a/src/zoomable-ink-viewport/__tests__/useViewportTransition.test.ts b/src/zoomable-ink-viewport/__tests__/useViewportTransition.test.ts new file mode 100644 index 0000000..f9183c5 --- /dev/null +++ b/src/zoomable-ink-viewport/__tests__/useViewportTransition.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook } from '@testing-library/react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import { useViewportTransition } from '../useViewportTransition'; + +const mockCompletions: Array<(finished: boolean) => void> = []; +jest.mock('react-native-reanimated', () => ({ + cancelAnimation: jest.fn(), + runOnJS: (callback: Function) => callback, + withTiming: (value: number, _options: unknown, completion: (finished: boolean) => void) => { + mockCompletions.push(completion); + return value; + }, +})); + +const shared = (value: T) => ({ value } as SharedValue); +function setup() { + const values = { + scale: shared(1), translateX: shared(0), translateY: shared(0), + savedScale: shared(1), savedTranslateX: shared(0), savedTranslateY: shared(0), + containerWidth: shared(800), containerHeight: shared(1000), + isGesturingRef: shared(false), isDecayingX: shared(false), isDecayingY: shared(false), + isClampingX: shared(false), isClampingY: shared(false), isMotionActive: shared(false), + }; + const events: unknown[] = []; + const motion = (moving: boolean) => events.push(moving); + const transform = (value: unknown) => events.push(value); + const hook = renderHook(() => useViewportTransition(values, motion, transform)); + return { ...hook, values, events }; +} +const destination = { scale: 2, translateX: 20, translateY: -99000 }; +const reported = { ...destination, containerWidth: 800, containerHeight: 1000 }; + +beforeEach(() => { mockCompletions.length = 0; }); + +it('reports an immediate destination before announcing that motion ended', () => { + const { result, values, events } = setup(); + act(() => result.current(destination, false)); + expect(events).toEqual([true, reported, false]); + expect(values.savedTranslateY.value).toBe(-99000); + expect(values.translateY.value).toBe(-99000); + expect(values.isMotionActive.value).toBe(false); +}); + +it('waits for all axes, then flushes the destination regardless of notification throttling', () => { + const { result, events } = setup(); + act(() => result.current(destination, true)); + act(() => { mockCompletions[0](true); mockCompletions[2](true); }); + expect(events).toEqual([true]); + act(() => mockCompletions[1](true)); + expect(events).toEqual([true, reported, false]); +}); + +it('does not let a superseded animation unlock a newer page jump', () => { + const { result, events } = setup(); + act(() => result.current(destination, true)); + act(() => result.current({ ...destination, translateY: -500 }, true)); + act(() => mockCompletions.slice(0, 3).forEach(callback => callback(true))); + expect(events).toEqual([true, true]); + act(() => mockCompletions.slice(3).forEach(callback => callback(true))); + expect(events).toEqual([true, true, { ...reported, translateY: -500 }, false]); +}); + +it('leaves an interrupting gesture in control of motion and its destination', () => { + const { result, values, events } = setup(); + act(() => result.current(destination, true)); + values.isGesturingRef.value = true; + act(() => mockCompletions.forEach(callback => callback(true))); + expect(events).toEqual([true]); +}); + +it('does not report a destination for cancelled animations or an unmounted viewport', () => { + const { result, unmount, events } = setup(); + act(() => result.current(destination, true)); + act(() => mockCompletions.forEach(callback => callback(false))); + expect(events).toEqual([true]); + unmount(); + act(() => mockCompletions.forEach(callback => callback(true))); + expect(events).toEqual([true]); +}); + +it('ignores queued completions after an interrupting gesture has already settled', () => { + const { result, values, events } = setup(); + act(() => result.current(destination, true)); + values.translateY.value = -1200; + values.isMotionActive.value = false; + act(() => mockCompletions.forEach(callback => callback(true))); + expect(events).toEqual([true]); + expect(values.translateY.value).toBe(-1200); +}); diff --git a/src/zoomable-ink-viewport/__tests__/viewportCallbacks.test.ts b/src/zoomable-ink-viewport/__tests__/viewportCallbacks.test.ts new file mode 100644 index 0000000..208666e --- /dev/null +++ b/src/zoomable-ink-viewport/__tests__/viewportCallbacks.test.ts @@ -0,0 +1,38 @@ +import {createViewportCallbacks} from '../viewportCallbacks'; + +test('native-held callback uses the latest committed consumer and releases it on disposal', () => { + const bridge = createViewportCallbacks(); + const nativeCallback = bridge.notifyTransformChange; + const oldConsumer = jest.fn(); + const newConsumer = jest.fn(); + bridge.update({onTransformChange: oldConsumer}); + nativeCallback(2, 10, 20, 500, 700); + bridge.update({onTransformChange: newConsumer}); + nativeCallback(3, 11, 21, 500, 700); + expect(oldConsumer).toHaveBeenCalledTimes(1); + expect(newConsumer).toHaveBeenCalledWith({scale: 3, translateX: 11, translateY: 21, + containerWidth: 500, containerHeight: 700}); + bridge.update(null); + nativeCallback(4, 12, 22, 500, 700); + expect(newConsumer).toHaveBeenCalledTimes(1); +}); + +test('forwards gesture, zoom, motion, and content events, then ignores queued events after unmount', () => { + const bridge = createViewportCallbacks(); + const onZoomChange = jest.fn(), onGestureStateChange = jest.fn(); + const onMotionStateChange = jest.fn(), onContentTap = jest.fn(); + bridge.update({onZoomChange, onGestureStateChange, onMotionStateChange, onContentTap}); + bridge.notifyZoomChange(2); bridge.notifyGestureStart(); bridge.notifyGestureEnd(); + bridge.notifyMotionChange(true); bridge.notifyContentTap(10, 20); + expect(onZoomChange).toHaveBeenCalledWith(2); + expect(onGestureStateChange.mock.calls).toEqual([[true], [false]]); + expect(onMotionStateChange).toHaveBeenCalledWith(true); + expect(onContentTap).toHaveBeenCalledWith({nativeEvent: {locationX: 10, locationY: 20, isZoomableContentTap: true}}); + bridge.update(null); + bridge.notifyZoomChange(3); bridge.notifyGestureStart(); bridge.notifyGestureEnd(); + bridge.notifyMotionChange(false); bridge.notifyContentTap(30, 40); + expect(onZoomChange).toHaveBeenCalledTimes(1); + expect(onGestureStateChange).toHaveBeenCalledTimes(2); + expect(onMotionStateChange).toHaveBeenCalledTimes(1); + expect(onContentTap).toHaveBeenCalledTimes(1); +}); diff --git a/src/zoomable-ink-viewport/geometry.ts b/src/zoomable-ink-viewport/geometry.ts index d198d34..1b7a380 100644 --- a/src/zoomable-ink-viewport/geometry.ts +++ b/src/zoomable-ink-viewport/geometry.ts @@ -108,3 +108,14 @@ export const shouldLockHorizontalTranslation = ( ) ); }; + +/** Invert the centered transform used by ZoomableInkViewport for all touch routing. */ +export function viewportPointToContent(x: number, y: number, scale: number, + translateX: number, translateY: number, width: number, height: number) { + 'worklet'; + const safeScale = scale > 0 ? scale : 1; + const originX = width / 2; + const originY = height / 2; + return {x: (x - originX - translateX) / safeScale + originX, + y: (y - originY - translateY) / safeScale + originY}; +} diff --git a/src/zoomable-ink-viewport/transitionCompletion.ts b/src/zoomable-ink-viewport/transitionCompletion.ts new file mode 100644 index 0000000..9c9ece5 --- /dev/null +++ b/src/zoomable-ink-viewport/transitionCompletion.ts @@ -0,0 +1,19 @@ +/** Native may retain this function after timing animations finish. Keep consumers + * in replaceable state and release them when finished, superseded, or unmounted. */ +export function createTransitionCompletion() { + let pending: {token: number; remaining: number; finish: () => void} | null = null; + return { + begin(token: number, axes: number, finish: () => void) { + pending = {token, remaining: axes, finish}; + }, + clear() { pending = null; }, + completeAxis(token: number, finished = true) { + if (!pending || pending.token !== token) return; + if (!finished) { pending = null; return; } + if (--pending.remaining > 0) return; + const finish = pending.finish; + pending = null; + finish(); + }, + }; +} diff --git a/src/zoomable-ink-viewport/types.ts b/src/zoomable-ink-viewport/types.ts index 87cea90..d729924 100644 --- a/src/zoomable-ink-viewport/types.ts +++ b/src/zoomable-ink-viewport/types.ts @@ -31,8 +31,12 @@ export interface TouchExclusionRect { export interface ZoomableInkViewportProps { children: ReactNode; style?: ViewStyle; + /** Identifies the actual gesture surface, excluding sibling toolbars. */ + testID?: string; minScale?: number; maxScale?: number; + /** Mount-time zoom, clamped to the zoom limits. Reset still returns to 1x. */ + initialScale?: number; enabled?: boolean; onZoomChange?: (scale: number) => void; /** Called when gesture state changes - use to block canvas touches */ diff --git a/src/zoomable-ink-viewport/useViewportTransition.ts b/src/zoomable-ink-viewport/useViewportTransition.ts new file mode 100644 index 0000000..2a64c3e --- /dev/null +++ b/src/zoomable-ink-viewport/useViewportTransition.ts @@ -0,0 +1,77 @@ +import { createTransitionCompletion } from './transitionCompletion'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { cancelAnimation, runOnJS, withTiming } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import type { ZoomableInkViewportProps } from './types'; + +type NumericKey = 'scale' | 'translateX' | 'translateY' | 'savedScale' | + 'savedTranslateX' | 'savedTranslateY' | 'containerWidth' | 'containerHeight'; +type MotionKey = 'isGesturingRef' | 'isDecayingX' | 'isDecayingY' | + 'isClampingX' | 'isClampingY' | 'isMotionActive'; +type Values = { [K in NumericKey]: SharedValue } & + { [K in MotionKey]: SharedValue }; +type Transform = { scale: number; translateX: number; translateY: number }; + +/** Imperative jumps must trigger the same pool handoff as a finished gesture. */ +export function useViewportTransition( + values: Values, + onMotionStateChange: ZoomableInkViewportProps['onMotionStateChange'], + onTransformChange: ZoomableInkViewportProps['onTransformChange'], +) { + const generation = useRef(0); + const completionBridge = useMemo(createTransitionCompletion, []); + const completeAxis = completionBridge.completeAxis; + useEffect(() => () => { generation.current += 1; completionBridge.clear(); }, [completionBridge]); + + return useCallback((next: Transform, animated: boolean) => { + const token = ++generation.current; + completionBridge.clear(); + cancelAnimation(values.scale); + cancelAnimation(values.translateX); + cancelAnimation(values.translateY); + values.isDecayingX.value = false; + values.isDecayingY.value = false; + values.isClampingX.value = false; + values.isClampingY.value = false; + values.isMotionActive.value = true; + onMotionStateChange?.(true); + + const finish = () => { + if (generation.current !== token || values.isGesturingRef.value || + values.isDecayingX.value || values.isDecayingY.value || + values.isClampingX.value || values.isClampingY.value) return; + // A gesture can start and finish before queued JS completions arrive. + // Never publish the old destination over its newer settled transform. + if (values.scale.value !== next.scale || + values.translateX.value !== next.translateX || + values.translateY.value !== next.translateY) return; + // Flush even if the last animation frame fell inside the JS notification + // throttle. Consumers need the destination before releasing their lock. + onTransformChange?.({ ...next, + containerWidth: values.containerWidth.value, + containerHeight: values.containerHeight.value, + }); + values.isMotionActive.value = false; + onMotionStateChange?.(false); + }; + + values.savedScale.value = next.scale; + values.savedTranslateX.value = next.translateX; + values.savedTranslateY.value = next.translateY; + if (animated) { + completionBridge.begin(token, 3, finish); + const completion = (finished?: boolean) => { + 'worklet'; + runOnJS(completeAxis)(token, finished === true); + }; + values.scale.value = withTiming(next.scale, { duration: 150 }, completion); + values.translateX.value = withTiming(next.translateX, { duration: 150 }, completion); + values.translateY.value = withTiming(next.translateY, { duration: 150 }, completion); + } else { + values.scale.value = next.scale; + values.translateX.value = next.translateX; + values.translateY.value = next.translateY; + finish(); + } + }, [values, onMotionStateChange, onTransformChange, completionBridge, completeAxis]); +} diff --git a/src/zoomable-ink-viewport/useZoomableViewportGestures.ts b/src/zoomable-ink-viewport/useZoomableViewportGestures.ts index 506feba..090b695 100644 --- a/src/zoomable-ink-viewport/useZoomableViewportGestures.ts +++ b/src/zoomable-ink-viewport/useZoomableViewportGestures.ts @@ -1,4 +1,5 @@ -import { useCallback, useMemo } from "react"; +import { useLayoutEffect, useMemo } from "react"; +import { createViewportCallbacks } from "./viewportCallbacks"; import { Gesture } from "react-native-gesture-handler"; import { cancelAnimation, @@ -8,6 +9,7 @@ import { } from "react-native-reanimated"; import type { SharedValue } from "react-native-reanimated"; import { + viewportPointToContent, clamp, clampTranslation, getTranslationBounds, @@ -105,51 +107,21 @@ export const useZoomableViewportGestures = ({ isMotionActive, lastTransformNotificationTs, }: UseZoomableViewportGesturesParams) => { - const notifyGestureStart = useCallback(() => { - onGestureStateChange?.(true); - }, [onGestureStateChange]); - - const notifyGestureEnd = useCallback(() => { - onGestureStateChange?.(false); - }, [onGestureStateChange]); - - const notifyMotionChange = useCallback((isMoving: boolean) => { - onMotionStateChange?.(isMoving); - }, [onMotionStateChange]); - - const notifyTransformChange = useCallback(( - nextScale: number, - nextTranslateX: number, - nextTranslateY: number, - nextContainerWidth: number, - nextContainerHeight: number - ) => { - onTransformChange?.({ - scale: nextScale, - translateX: nextTranslateX, - translateY: nextTranslateY, - containerWidth: nextContainerWidth, - containerHeight: nextContainerHeight, - }); - }, [onTransformChange]); - - const notifyContentTap = useCallback((locationX: number, locationY: number) => { - onContentTap?.({ - nativeEvent: { - locationX, - locationY, - isZoomableContentTap: true, - }, - }); - }, [onContentTap]); + const callbacks = useMemo(createViewportCallbacks, []); + useLayoutEffect(() => { + callbacks.update({onZoomChange, onGestureStateChange, onMotionStateChange, + onTransformChange, onContentTap}); + return () => callbacks.update(null); + }, [callbacks, onZoomChange, onGestureStateChange, onMotionStateChange, + onTransformChange, onContentTap]); + const {notifyGestureStart, notifyGestureEnd, notifyMotionChange, + notifyTransformChange, notifyContentTap, notifyZoomChange} = callbacks; + const contentTapEnabled = !!onContentTap; const isTouchBlocked = (x: number, y: number): boolean => { "worklet"; - const currentScale = scale.value > 0 ? scale.value : 1; - const originX = containerWidth.value / 2; - const originY = containerHeight.value / 2; - const contentX = (x - originX - translateX.value) / currentScale + originX; - const contentY = (y - originY - translateY.value) / currentScale + originY; + const {x: contentX, y: contentY} = viewportPointToContent(x, y, scale.value, + translateX.value, translateY.value, containerWidth.value, containerHeight.value); for (const rect of blockedRects.value) { if ( @@ -388,9 +360,7 @@ export const useZoomableViewportGestures = ({ syncMotionState(); - if (onZoomChange) { - runOnJS(onZoomChange)(scale.value); - } + runOnJS(notifyZoomChange)(scale.value); }) .onFinalize(() => { "worklet"; @@ -400,7 +370,7 @@ export const useZoomableViewportGestures = ({ syncMotionState(); } }); - }, [enabled, minScale, maxScale, onZoomChange, notifyGestureStart, notifyGestureEnd, notifyMotionChange, notifyTransformChange]); + }, [enabled, minScale, maxScale, notifyZoomChange, notifyGestureStart, notifyGestureEnd, notifyMotionChange, notifyTransformChange]); const panGesture = useMemo(() => { const gesture = Gesture.Pan() @@ -653,7 +623,7 @@ export const useZoomableViewportGestures = ({ const tapGesture = useMemo(() => { return Gesture.Tap() - .enabled(enabled && !!onContentTap) + .enabled(enabled && contentTapEnabled) .maxDistance(10) .maxDuration(350) .onTouchesDown((event, stateManager) => { @@ -668,12 +638,11 @@ export const useZoomableViewportGestures = ({ return; } - const currentScale = scale.value > 0 ? scale.value : 1; - const locationX = (event.x - translateX.value) / currentScale; - const locationY = (event.y - translateY.value) / currentScale; - runOnJS(notifyContentTap)(locationX, locationY); + const point = viewportPointToContent(event.x, event.y, scale.value, + translateX.value, translateY.value, containerWidth.value, containerHeight.value); + runOnJS(notifyContentTap)(point.x, point.y); }); - }, [enabled, notifyContentTap, onContentTap, scale, translateX, translateY]); + }, [enabled, notifyContentTap, contentTapEnabled, scale, translateX, translateY]); return useMemo(() => { return Gesture.Simultaneous(pinchGesture, panGesture, tapGesture); diff --git a/src/zoomable-ink-viewport/viewportCallbacks.ts b/src/zoomable-ink-viewport/viewportCallbacks.ts new file mode 100644 index 0000000..7449be0 --- /dev/null +++ b/src/zoomable-ink-viewport/viewportCallbacks.ts @@ -0,0 +1,23 @@ +import type { ZoomableInkViewportProps } from './types'; + +export type ViewportCallbacks = Pick; + +/** Keep native-held functions outside the render closure that owns notebook data. */ +export function createViewportCallbacks() { + let current: ViewportCallbacks | null = null; + return { + update(next: ViewportCallbacks | null) { current = next; }, + notifyZoomChange: (scale: number) => current?.onZoomChange?.(scale), + notifyGestureStart: () => current?.onGestureStateChange?.(true), + notifyGestureEnd: () => current?.onGestureStateChange?.(false), + notifyMotionChange: (moving: boolean) => current?.onMotionStateChange?.(moving), + notifyTransformChange: (scale: number, translateX: number, translateY: number, + containerWidth: number, containerHeight: number) => current?.onTransformChange?.({ + scale, translateX, translateY, containerWidth, containerHeight, + }), + notifyContentTap: (locationX: number, locationY: number) => current?.onContentTap?.({ + nativeEvent: { locationX, locationY, isZoomableContentTap: true }, + }), + }; +} From 88ed9ebbcc991fa59bc74c097e779660417c5edc Mon Sep 17 00:00:00 2001 From: markm39 Date: Mon, 12 Oct 2026 07:01:48 -0500 Subject: [PATCH 2/3] ci: use supported Java setup action --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76f9e88..4d896f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: run: npm run test:native:zoom-smoke - name: Set up Java for document JNI regression test - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' From bb0de2de3978b36a1e3fc0ba04f41d2a47b62cdb Mon Sep 17 00:00:00 2001 From: markm39 Date: Mon, 12 Oct 2026 07:11:03 -0500 Subject: [PATCH 3/3] fix(ios): contain document codec exceptions at bridge boundary --- ios/MobileInkModule/MobileInkDocumentBridge.mm | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ios/MobileInkModule/MobileInkDocumentBridge.mm b/ios/MobileInkModule/MobileInkDocumentBridge.mm index 861d88d..e72cc15 100644 --- a/ios/MobileInkModule/MobileInkDocumentBridge.mm +++ b/ios/MobileInkModule/MobileInkDocumentBridge.mm @@ -73,6 +73,9 @@ - (void)loadBase64Data:(NSNumber*)viewTag base64String:(NSString*)payload @interface MobileInkDocumentBridge : NSObject @end +// Skia archives can supply exception RTTI that prevents std::exception matching. +// Keep catch-all rejection at every exported C++ boundary; never let an invalid +// notebook unwind through React Native or resolve it as an empty document. @implementation MobileInkDocumentBridge RCT_EXPORT_MODULE() @synthesize bridge = _bridge; @@ -91,6 +94,8 @@ - (dispatch_queue_t)methodQueue { return dispatch_get_global_queue(QOS_CLASS_USE resolve(encodePage(document, false)); } catch (const std::exception& error) { reject(@"DOCUMENT_COMPOSE_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } catch (...) { + reject(@"DOCUMENT_COMPOSE_FAILED", @"Unable to process drawing data", nil); } } // Compose off the UI thread, then reuse the canvas's confirmed asynchronous load. @@ -127,6 +132,8 @@ - (dispatch_queue_t)methodQueue { return dispatch_get_global_queue(QOS_CLASS_USE }); } catch (const std::exception& error) { reject(@"DOCUMENT_LOAD_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } catch (...) { + reject(@"DOCUMENT_LOAD_FAILED", @"Unable to process drawing data", nil); } } RCT_EXPORT_METHOD(partitionDocument:(NSString*)payload @@ -143,6 +150,8 @@ - (dispatch_queue_t)methodQueue { return dispatch_get_global_queue(QOS_CLASS_USE resolve(result); } catch (const std::exception& error) { reject(@"DOCUMENT_PARTITION_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } catch (...) { + reject(@"DOCUMENT_PARTITION_FAILED", @"Unable to process drawing data", nil); } } RCT_EXPORT_METHOD(projectDocumentPages:(NSArray*)payloads @@ -171,6 +180,8 @@ - (dispatch_queue_t)methodQueue { return dispatch_get_global_queue(QOS_CLASS_USE resolve(result); } catch (const std::exception& error) { reject(@"DOCUMENT_EXPORT_FAILED", [NSString stringWithUTF8String:error.what()], nil); + } catch (...) { + reject(@"DOCUMENT_EXPORT_FAILED", @"Unable to process drawing data", nil); } } @end