From 0577df8395538694247aed2d1dd2ef9c467ae9b0 Mon Sep 17 00:00:00 2001 From: markm39 Date: Tue, 19 May 2026 21:13:20 -0500 Subject: [PATCH] feat(ink): add crisp zoom rendering --- android/src/main/cpp/jni_bridge.cpp | 27 +- .../mobileink/MobileInkCanvasView.kt | 130 +++++- .../mobileink/MobileInkCanvasViewManager.kt | 25 + cpp/SkiaDrawingEngine.cpp | 18 +- cpp/SkiaDrawingEngine.h | 55 +++ cpp/SkiaDrawingEngineEraser.cpp | 19 +- cpp/SkiaDrawingEngineRendering.cpp | 433 +++++++++++++++++- cpp/SkiaDrawingEngineSelection.cpp | 12 +- example/app.json | 5 +- .../with-xcode-asset-symbol-workaround.js | 29 ++ ios/MobileInkModule/MobileInkBridge.mm | 19 + ios/MobileInkModule/MobileInkCanvasView.swift | 121 ++++- .../MobileInkCanvasViewManager.m | 5 + .../MobileInkCanvasViewManager.swift | 27 ++ ios/MobileInkModule/SkiaEngineBridge.swift | 10 + src/ContinuousEnginePool.tsx | 16 + src/InfiniteInkCanvas.tsx | 46 +- src/NativeInkCanvas.tsx | 5 + src/__tests__/ContinuousEnginePool.test.tsx | 30 ++ .../PooledCanvasSlot.tsx | 60 +++ src/continuous-engine-pool/types.ts | 14 + src/infinite-ink-canvas/notebookPages.ts | 7 +- src/native-ink-canvas/types.ts | 7 + src/utils/__tests__/nativeRenderScale.test.ts | 24 + src/utils/__tests__/viewportTransform.test.ts | 37 ++ src/utils/nativeRenderScale.ts | 34 ++ src/utils/viewportTransform.ts | 51 +++ .../useZoomableViewportGestures.ts | 19 +- 28 files changed, 1214 insertions(+), 71 deletions(-) create mode 100644 example/plugins/with-xcode-asset-symbol-workaround.js create mode 100644 src/utils/__tests__/nativeRenderScale.test.ts create mode 100644 src/utils/__tests__/viewportTransform.test.ts create mode 100644 src/utils/nativeRenderScale.ts create mode 100644 src/utils/viewportTransform.ts diff --git a/android/src/main/cpp/jni_bridge.cpp b/android/src/main/cpp/jni_bridge.cpp index 39990d7..cca73f2 100644 --- a/android/src/main/cpp/jni_bridge.cpp +++ b/android/src/main/cpp/jni_bridge.cpp @@ -388,6 +388,27 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_destroyDrawingEngine( delete ctx; } +JNIEXPORT void JNICALL +Java_com_mathnotes_mobileink_MobileInkCanvasView_setRenderViewport( + JNIEnv* env, jobject obj, jlong contextPtr, + jfloat renderScale, + jfloat visibleLeft, + jfloat visibleTop, + jfloat visibleWidth, + jfloat visibleHeight) { + + auto* ctx = reinterpret_cast(contextPtr); + if (ctx && ctx->engine) { + ctx->engine->setRenderViewport( + renderScale, + visibleLeft, + visibleTop, + visibleWidth, + visibleHeight + ); + } +} + // Touch handling with stylus support (pressure, azimuth, altitude) JNIEXPORT void JNICALL Java_com_mathnotes_mobileink_MobileInkCanvasView_touchBegan( @@ -923,7 +944,11 @@ Java_com_mathnotes_mobileink_MobileInkCanvasView_renderToPixelsScaled( const float resolvedScale = scale > 0.0f ? scale : 1.0f; canvas->save(); canvas->scale(resolvedScale, resolvedScale); - renderWithDisplayColorOrdering(ctx->engine.get(), canvas); + SkPaint outputPaint; + outputPaint.setColorFilter(makeRedBlueSwapFilter()); + canvas->saveLayer(nullptr, &outputPaint); + ctx->engine->renderForExport(canvas); + canvas->restore(); canvas->restore(); AndroidBitmap_unlockPixels(env, bitmap); diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt index 5b14be1..6e8f608 100644 --- a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasView.kt @@ -26,8 +26,10 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. private val stylusPressureExponent: Float = 0.88f @Volatile private var drawingEngine: Long = 0 - @Volatile private var viewWidth: Int = 0 - @Volatile private var viewHeight: Int = 0 + @Volatile private var renderWidth: Int = 0 + @Volatile private var renderHeight: Int = 0 + private var logicalWidth: Int = 0 + private var logicalHeight: Int = 0 private var engineWidth: Int = 0 private var engineHeight: Int = 0 private var nativeLibraryAvailable: Boolean = false @@ -70,6 +72,43 @@ 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 renderScale: Float = 1f + set(value) { + val clamped = value.takeIf { it.isFinite() }?.coerceIn(1f, 5f) ?: 1f + if (field == clamped) return + field = clamped + surfaceTexture?.let { texture -> + if (isAvailable) { + configureAvailableTexture(texture, width, height) + } + } + applyRenderViewport() + requestInkRender(force = true) + } + var renderViewportLeftRatio: Float = 0f + set(value) { + field = value.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + applyRenderViewport() + requestInkRender(force = true) + } + var renderViewportTopRatio: Float = 0f + set(value) { + field = value.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + applyRenderViewport() + requestInkRender(force = true) + } + var renderViewportWidthRatio: Float = 1f + set(value) { + field = value.takeIf { it.isFinite() }?.coerceIn(0.001f, 1f) ?: 1f + applyRenderViewport() + requestInkRender(force = true) + } + var renderViewportHeightRatio: Float = 1f + set(value) { + field = value.takeIf { it.isFinite() }?.coerceIn(0.001f, 1f) ?: 1f + applyRenderViewport() + requestInkRender(force = true) + } var renderSuspended: Boolean = false set(value) { if (field == value) return @@ -120,7 +159,9 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. return } - surfaceTexture.setDefaultBufferSize(width, height) + val targetWidth = maxOf(1, kotlin.math.ceil(width * renderScale).toInt()) + val targetHeight = maxOf(1, kotlin.math.ceil(height * renderScale).toInt()) + surfaceTexture.setDefaultBufferSize(targetWidth, targetHeight) queueEvent { if ( !surfaceReady || @@ -128,17 +169,26 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. eglContext == EGL14.EGL_NO_CONTEXT || eglSurface == EGL14.EGL_NO_SURFACE ) { - createRenderSurface(surfaceTexture, width, height) + createRenderSurface(surfaceTexture, width, height, targetWidth, targetHeight) + } else if (targetWidth != renderWidth || targetHeight != renderHeight) { + createRenderSurface(surfaceTexture, width, height, targetWidth, targetHeight) } else if (makeRenderContextCurrent()) { - configureSurfaceSize(width, height) + configureSurfaceSize(width, height, targetWidth, targetHeight) renderFrame() } } } - private fun configureSurfaceSize(width: Int, height: Int) { - viewWidth = width - viewHeight = height + private fun configureSurfaceSize( + width: Int, + height: Int, + targetWidth: Int = maxOf(1, kotlin.math.ceil(width * renderScale).toInt()), + targetHeight: Int = maxOf(1, kotlin.math.ceil(height * renderScale).toInt()), + ) { + logicalWidth = width + logicalHeight = height + renderWidth = targetWidth + renderHeight = targetHeight if (width <= 0 || height <= 0) { return @@ -151,6 +201,8 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. val shouldPreserveDrawing = drawingEngine != 0L if (drawingEngine == 0L || engineWidth != width || engineHeight != height) { configureDrawingEngine(width, height, shouldPreserveDrawing) + } else { + applyRenderViewport() } // Re-apply PDF background if we have one (needs to be re-rendered at new size) @@ -159,8 +211,14 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. } } - private fun createRenderSurface(surfaceTexture: SurfaceTexture, width: Int, height: Int) { - if (width <= 0 || height <= 0) { + private fun createRenderSurface( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + targetWidth: Int, + targetHeight: Int, + ) { + if (width <= 0 || height <= 0 || targetWidth <= 0 || targetHeight <= 0) { return } @@ -248,7 +306,7 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. } surfaceReady = true - configureSurfaceSize(width, height) + configureSurfaceSize(width, height, targetWidth, targetHeight) renderFrame() } @@ -327,9 +385,11 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. setBackgroundType(drawingEngine, currentBackgroundType) applyCurrentToolToEngine(drawingEngine) + applyRenderViewport() if (preservedDrawing != null && preservedDrawing.isNotEmpty()) { deserializeDrawing(drawingEngine, preservedDrawing) + applyRenderViewport() } resetTransientInteractionState() @@ -338,17 +398,50 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. private fun renderFrame() { val engine = drawingEngine - if (!surfaceReady || engine == 0L || viewWidth <= 0 || viewHeight <= 0) { + if (!surfaceReady || engine == 0L || renderWidth <= 0 || renderHeight <= 0) { return } if (!makeRenderContextCurrent()) { return } - if (renderGaneshToCurrentSurface(engine, viewWidth, viewHeight)) { + if (renderGaneshToCurrentSurface(engine, renderWidth, renderHeight)) { EGL14.eglSwapBuffers(eglDisplay, eglSurface) } } + private fun applyRenderViewport() { + val engine = drawingEngine + if (engine == 0L || engineWidth <= 0 || engineHeight <= 0) { + return + } + + val left = renderViewportLeftRatio.coerceIn(0f, 1f) * engineWidth + val top = renderViewportTopRatio.coerceIn(0f, 1f) * engineHeight + val viewportWidth = minOf( + engineWidth.toFloat() - left, + renderViewportWidthRatio.coerceIn(0.001f, 1f) * engineWidth + ) + val viewportHeight = minOf( + engineHeight.toFloat() - top, + renderViewportHeightRatio.coerceIn(0.001f, 1f) * engineHeight + ) + + val actualRenderScale = if (renderWidth > 0 && engineWidth > 0) { + (renderWidth.toFloat() / engineWidth.toFloat()).coerceIn(1f, 5f) + } else { + renderScale.coerceIn(1f, 5f) + } + + setRenderViewport( + engine, + actualRenderScale, + left, + top, + maxOf(1f, viewportWidth), + maxOf(1f, viewportHeight) + ) + } + private fun ensureRenderHandler(): Handler { synchronized(renderThreadLock) { val existingThread = renderThread @@ -421,7 +514,7 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. // Load PDF and render to bitmap on background thread Thread { - val pdfBitmap = PdfLoader.loadAndRenderPdf(context, uri, viewWidth, viewHeight) + val pdfBitmap = PdfLoader.loadAndRenderPdf(context, uri, logicalWidth, logicalHeight) if (pdfBitmap != null) { queueEvent { if (drawingEngine != 0L) { @@ -1197,12 +1290,12 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. } fun getBase64PngData(scale: Float): String? { - if (drawingEngine == 0L || viewWidth == 0 || viewHeight == 0) return null + if (drawingEngine == 0L || logicalWidth == 0 || logicalHeight == 0) return null return runOnGlThreadSync { exportToBase64(scale, Bitmap.CompressFormat.PNG, 100) } } fun getBase64JpegData(scale: Float, compression: Float): String? { - if (drawingEngine == 0L || viewWidth == 0 || viewHeight == 0) return null + if (drawingEngine == 0L || logicalWidth == 0 || logicalHeight == 0) return null val quality = (compression * 100).toInt().coerceIn(0, 100) return runOnGlThreadSync { exportToBase64(scale, Bitmap.CompressFormat.JPEG, quality) } } @@ -1210,8 +1303,8 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. private fun exportToBase64(scale: Float, format: Bitmap.CompressFormat, quality: Int): String? { return try { val actualScale = if (scale > 0f) scale else resources.displayMetrics.density - val width = (viewWidth * actualScale).toInt().coerceAtLeast(1) - val height = (viewHeight * actualScale).toInt().coerceAtLeast(1) + val width = (logicalWidth * actualScale).toInt().coerceAtLeast(1) + val height = (logicalHeight * actualScale).toInt().coerceAtLeast(1) val finalBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) if (drawingEngine != 0L) renderToPixelsScaled(drawingEngine, finalBitmap, actualScale) @@ -1262,6 +1355,7 @@ class MobileInkCanvasView(context: Context) : TextureView(context), TextureView. // Native method declarations private external fun createDrawingEngine(width: Int, height: Int): Long private external fun destroyDrawingEngine(engine: Long) + private external fun setRenderViewport(engine: Long, renderScale: Float, visibleLeft: Float, visibleTop: Float, visibleWidth: Float, visibleHeight: Float) // Touch handling with full stylus support private external fun touchBegan(engine: Long, x: Float, y: Float, pressure: Float, azimuth: Float, altitude: Float, timestamp: Long, isStylusInput: Boolean) diff --git a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt index 3a0e77e..7d92b1c 100644 --- a/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt +++ b/android/src/main/java/com/mathnotes/mobileink/MobileInkCanvasViewManager.kt @@ -65,6 +65,31 @@ class MobileInkCanvasViewManager(private val reactContext: ReactApplicationConte view.renderBackend = backend ?: "ganesh" } + @ReactProp(name = "renderScale") + fun setRenderScale(view: MobileInkCanvasView, scale: Float) { + view.renderScale = scale + } + + @ReactProp(name = "renderViewportLeftRatio") + fun setRenderViewportLeftRatio(view: MobileInkCanvasView, ratio: Float) { + view.renderViewportLeftRatio = ratio + } + + @ReactProp(name = "renderViewportTopRatio") + fun setRenderViewportTopRatio(view: MobileInkCanvasView, ratio: Float) { + view.renderViewportTopRatio = ratio + } + + @ReactProp(name = "renderViewportWidthRatio") + fun setRenderViewportWidthRatio(view: MobileInkCanvasView, ratio: Float) { + view.renderViewportWidthRatio = ratio + } + + @ReactProp(name = "renderViewportHeightRatio") + fun setRenderViewportHeightRatio(view: MobileInkCanvasView, ratio: Float) { + view.renderViewportHeightRatio = ratio + } + private fun parseToolColor(colorHex: String): Int { val trimmed = colorHex.trim() val hex = trimmed.removePrefix("#") diff --git a/cpp/SkiaDrawingEngine.cpp b/cpp/SkiaDrawingEngine.cpp index 1963982..a6d2ecc 100644 --- a/cpp/SkiaDrawingEngine.cpp +++ b/cpp/SkiaDrawingEngine.cpp @@ -63,6 +63,8 @@ SkiaDrawingEngine::SkiaDrawingEngine(int width, int height) , currentTool_("pen") , eraserMode_("pixel") , needsStrokeRedraw_(true) + , visibleWidth_(static_cast(width)) + , visibleHeight_(static_cast(height)) , needsEraserMaskRedraw_(true) , hasLastSmoothedPoint_(false) , eraserCursorX_(0) @@ -602,6 +604,12 @@ void SkiaDrawingEngine::finishStroke(long endTimestamp) { } strokes_.push_back(stroke); + SkRect appendedStrokeBounds = stroke.path.getBounds(); + appendedStrokeBounds.outset( + std::max(8.0f, stroke.paint.getStrokeWidth() * 2.0f), + std::max(8.0f, stroke.paint.getStrokeWidth() * 2.0f) + ); + invalidateStrokeTilesForRect(appendedStrokeBounds); // === FAST PATH: Composite active stroke directly === // This preserves O(1) stroke completion for smooth performance. @@ -635,7 +643,7 @@ void SkiaDrawingEngine::finishStroke(long endTimestamp) { maxAffectedStrokeIndex_ = strokes_.size(); } else { // Fallback for eraser or if surfaces unavailable - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } // Single-stroke append. The new stroke is the last element in @@ -694,7 +702,7 @@ void SkiaDrawingEngine::clear() { commitDelta(std::move(delta)); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); needsEraserMaskRedraw_ = true; } @@ -711,7 +719,7 @@ void SkiaDrawingEngine::undo() { bakedCircleCount_ = 0; clearActiveShapePreview(); activeStrokeRenderer_->reset(); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); needsEraserMaskRedraw_ = true; } @@ -728,7 +736,7 @@ void SkiaDrawingEngine::redo() { bakedCircleCount_ = 0; clearActiveShapePreview(); activeStrokeRenderer_->reset(); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); needsEraserMaskRedraw_ = true; } @@ -845,7 +853,7 @@ bool SkiaDrawingEngine::deserializeDrawing(const std::vector& data) { // checkpoint -- the user wouldn't expect to undo past the load. undoStack_.clear(); redoStack_.clear(); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); needsEraserMaskRedraw_ = true; return true; } diff --git a/cpp/SkiaDrawingEngine.h b/cpp/SkiaDrawingEngine.h index 3b17c92..6911b35 100644 --- a/cpp/SkiaDrawingEngine.h +++ b/cpp/SkiaDrawingEngine.h @@ -19,6 +19,7 @@ #include #include #include +#include #include "DrawingTypes.h" namespace nativedrawing { @@ -86,6 +87,14 @@ class SkiaDrawingEngine { // Rendering void render(SkCanvas* canvas); + void renderForExport(SkCanvas* canvas); + void setRenderViewport( + float renderScale, + float visibleLeft, + float visibleTop, + float visibleWidth, + float visibleHeight + ); sk_sp makeSnapshot(); // Batch export: Render multiple pages to PNG without creating new engine each time @@ -192,6 +201,46 @@ class SkiaDrawingEngine { sk_sp cachedStrokeSnapshot_; // Cached snapshot for fast rendering bool needsStrokeRedraw_; + struct StrokeTileKey { + int scaleBucket = 100; + int tileX = 0; + int tileY = 0; + uint64_t epoch = 0; + + bool operator==(const StrokeTileKey& other) const { + return scaleBucket == other.scaleBucket + && tileX == other.tileX + && tileY == other.tileY + && epoch == other.epoch; + } + }; + + struct StrokeTileKeyHasher { + size_t operator()(const StrokeTileKey& key) const { + uint64_t value = static_cast(static_cast(key.scaleBucket)); + value = (value * 1315423911u) ^ static_cast(key.tileX); + value = (value * 1315423911u) ^ static_cast(key.tileY); + value = (value * 1315423911u) ^ key.epoch; + return static_cast(value); + } + }; + + struct StrokeTileEntry { + sk_sp image; + size_t bytes = 0; + uint64_t lastUsed = 0; + }; + + std::unordered_map strokeTileCache_; + uint64_t strokeTileEpoch_ = 1; + uint64_t strokeTileUseCounter_ = 0; + size_t strokeTileCacheBytes_ = 0; + float renderScale_ = 1.0f; + float visibleLeft_ = 0.0f; + float visibleTop_ = 0.0f; + 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 @@ -258,6 +307,12 @@ class SkiaDrawingEngine { void clearActiveShapePreview(); bool updateActiveShapePreviewForPoint(float x, float y); void redrawStrokes(); + void markStrokeCachesDirty(); + void invalidateStrokeTilesForRect(const SkRect& bounds); + void renderScaleAwareStrokes(SkCanvas* canvas); + void renderActiveContent(SkCanvas* canvas, bool useIncrementalActiveSurface); + sk_sp renderStrokeTile(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 diff --git a/cpp/SkiaDrawingEngineEraser.cpp b/cpp/SkiaDrawingEngineEraser.cpp index 292b214..609eb2e 100644 --- a/cpp/SkiaDrawingEngineEraser.cpp +++ b/cpp/SkiaDrawingEngineEraser.cpp @@ -50,7 +50,7 @@ void SkiaDrawingEngine::eraseObjects() { if (remaining.size() != strokes_.size()) { strokes_ = remaining; commitDelta(std::move(delta)); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } }; @@ -195,6 +195,23 @@ bool SkiaDrawingEngine::applyPixelEraserAt(float eraserX, float eraserY, float r } if (anyModified) { + SkRect erasedBounds = SkRect::MakeLTRB( + eraserX - radius, + eraserY - radius, + eraserX + radius, + eraserY + radius + ); + if (hasLastEraserPoint_) { + erasedBounds.join(SkRect::MakeLTRB( + lastEraserX_ - lastEraserRadius_, + lastEraserY_ - lastEraserRadius_, + lastEraserX_ + lastEraserRadius_, + lastEraserY_ + lastEraserRadius_ + )); + } + erasedBounds.outset(radius * 2.0f, radius * 2.0f); + invalidateStrokeTilesForRect(erasedBounds); + // OPTIMIZATION: Apply kClear directly to stroke surface for instant feedback // This avoids full redraw during active erasing - much faster if (strokeSurface_) { diff --git a/cpp/SkiaDrawingEngineRendering.cpp b/cpp/SkiaDrawingEngineRendering.cpp index 76cbdeb..45a2ede 100644 --- a/cpp/SkiaDrawingEngineRendering.cpp +++ b/cpp/SkiaDrawingEngineRendering.cpp @@ -19,6 +19,11 @@ namespace nativedrawing { namespace { +constexpr int kStrokeTileSize = 512; +constexpr size_t kMaxStrokeTileCacheBytes = 96 * 1024 * 1024; +constexpr float kMinimumRenderScale = 1.0f; +constexpr float kMaximumRenderScale = 5.0f; + SkColor swapRedBlueChannels(SkColor color) { return SkColorSetARGB( SkColorGetA(color), @@ -38,8 +43,90 @@ 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_); @@ -149,6 +236,221 @@ 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::renderActiveContent(SkCanvas* canvas, bool useIncrementalActiveSurface) { + if (hasActiveShapePreview_ && !activeShapePreviewPoints_.empty()) { + Stroke previewStroke; + previewStroke.points = activeShapePreviewPoints_; + previewStroke.paint = currentPaint_; + previewStroke.path = activeShapePreviewPath_; + previewStroke.toolType = activeShapePreviewToolType_; + + SkPaint previewPaint = currentPaint_; + if (currentTool_ != "highlighter" && currentTool_ != "marker") { + const float pressureAlphaMod = 0.85f + (averagePressure(currentPoints_) * 0.15f); + previewPaint.setAlpha(static_cast(previewPaint.getAlpha() * pressureAlphaMod)); + } + + renderStrokeGeometry(canvas, previewStroke, previewPaint); + } else if (currentPoints_.size() >= 2 && currentTool_ != "select" && currentTool_ != "eraser") { + if (useIncrementalActiveSurface) { + activeStrokeRenderer_->renderIncremental(canvas, currentPoints_, currentPaint_, currentTool_); + } else { + Stroke activeStroke; + activeStroke.points = currentPoints_; + activeStroke.paint = currentPaint_; + activeStroke.toolType = currentTool_; + renderStrokeGeometry(canvas, activeStroke, currentPaint_); + } + } +} + void SkiaDrawingEngine::redrawEraserMask() { if (!needsEraserMaskRedraw_) return; @@ -179,6 +481,61 @@ void SkiaDrawingEngine::redrawEraserMask() { void SkiaDrawingEngine::render(SkCanvas* canvas) { std::lock_guard lock(stateMutex_); + const bool useScaleAwarePath = renderScale_ > 1.01f; + + if (useScaleAwarePath) { + if (backgroundType_ == "pdf") { + if (pdfBackgroundImage_) { + canvas->clear(SK_ColorWHITE); + } else { + canvas->clear(SK_ColorTRANSPARENT); + } + } else { + canvas->clear(SK_ColorWHITE); + } + + canvas->save(); + canvas->scale(renderScale_, renderScale_); + if (backgroundType_ != "pdf" || pdfBackgroundImage_) { + backgroundRenderer_->drawBackground( + canvas, + backgroundType_, + width_, + height_, + pdfBackgroundImage_, + backgroundOriginY_ + ); + } + canvas->restore(); + + renderScaleAwareStrokes(canvas); + + canvas->save(); + canvas->scale(renderScale_, renderScale_); + renderActiveContent(canvas, false); + + if (showEraserCursor_ && eraserCursorRadius_ > 0) { + SkPaint cursorPaint; + cursorPaint.setStyle(SkPaint::kStroke_Style); + cursorPaint.setColor(SkColorSetARGB(180, 128, 128, 128)); + cursorPaint.setStrokeWidth(2.0f); + cursorPaint.setAntiAlias(true); + canvas->drawCircle(eraserCursorX_, eraserCursorY_, eraserCursorRadius_, cursorPaint); + } + + if (currentTool_ == "select") { + selection_->renderLasso(canvas); + } + + if (isDraggingSelection_ && selectionHighlightSnapshot_) { + canvas->drawImage(selectionHighlightSnapshot_, selectionOffsetX_, selectionOffsetY_); + } else { + selection_->renderSelection(canvas, strokes_, selectedIndices_); + } + canvas->restore(); + return; + } + // OPTIMIZATION: When dragging selection, use all cached snapshots - pure O(1) per frame if (!selectedIndices_.empty() && isDraggingSelection_) { // Draw cached background - O(1) @@ -301,23 +658,7 @@ void SkiaDrawingEngine::render(SkCanvas* canvas) { } // 4. Draw active stroke incrementally (O(1) per frame instead of O(n)) - if (hasActiveShapePreview_ && !activeShapePreviewPoints_.empty()) { - Stroke previewStroke; - previewStroke.points = activeShapePreviewPoints_; - previewStroke.paint = currentPaint_; - previewStroke.path = activeShapePreviewPath_; - previewStroke.toolType = activeShapePreviewToolType_; - - SkPaint previewPaint = currentPaint_; - if (currentTool_ != "highlighter" && currentTool_ != "marker") { - const float pressureAlphaMod = 0.85f + (averagePressure(currentPoints_) * 0.15f); - previewPaint.setAlpha(static_cast(previewPaint.getAlpha() * pressureAlphaMod)); - } - - renderStrokeGeometry(canvas, previewStroke, previewPaint); - } else if (currentPoints_.size() >= 2 && currentTool_ != "select" && currentTool_ != "eraser") { - activeStrokeRenderer_->renderIncremental(canvas, currentPoints_, currentPaint_, currentTool_); - } + renderActiveContent(canvas, true); // Draw eraser cursor for pixel eraser if (showEraserCursor_ && eraserCursorRadius_ > 0) { @@ -344,12 +685,50 @@ void SkiaDrawingEngine::render(SkCanvas* canvas) { } } +void SkiaDrawingEngine::renderForExport(SkCanvas* canvas) { + std::lock_guard lock(stateMutex_); + + const float originalRenderScale = renderScale_; + const float originalVisibleLeft = visibleLeft_; + const float originalVisibleTop = visibleTop_; + const float originalVisibleWidth = visibleWidth_; + const float originalVisibleHeight = visibleHeight_; + + renderScale_ = 1.0f; + visibleLeft_ = 0.0f; + visibleTop_ = 0.0f; + visibleWidth_ = static_cast(width_); + visibleHeight_ = static_cast(height_); + render(canvas); + + renderScale_ = originalRenderScale; + visibleLeft_ = originalVisibleLeft; + visibleTop_ = originalVisibleTop; + visibleWidth_ = originalVisibleWidth; + visibleHeight_ = originalVisibleHeight; +} + sk_sp SkiaDrawingEngine::makeSnapshot() { std::lock_guard lock(stateMutex_); SkImageInfo info = SkImageInfo::MakeN32Premul(width_, height_); sk_sp surface = SkSurfaces::Raster(info); + const float originalRenderScale = renderScale_; + const float originalVisibleLeft = visibleLeft_; + const float originalVisibleTop = visibleTop_; + const float originalVisibleWidth = visibleWidth_; + const float originalVisibleHeight = visibleHeight_; + renderScale_ = 1.0f; + visibleLeft_ = 0.0f; + visibleTop_ = 0.0f; + visibleWidth_ = static_cast(width_); + visibleHeight_ = static_cast(height_); render(surface->getCanvas()); + renderScale_ = originalRenderScale; + visibleLeft_ = originalVisibleLeft; + visibleTop_ = originalVisibleTop; + visibleWidth_ = originalVisibleWidth; + visibleHeight_ = originalVisibleHeight; return surface->makeImageSnapshot(); } @@ -383,6 +762,17 @@ std::vector SkiaDrawingEngine::batchExportPages( float originalBackgroundOriginY = backgroundOriginY_; auto originalUndoStack = undoStack_; auto originalRedoStack = redoStack_; + const float originalRenderScale = renderScale_; + const float originalVisibleLeft = visibleLeft_; + const float originalVisibleTop = visibleTop_; + const float originalVisibleWidth = visibleWidth_; + const float originalVisibleHeight = visibleHeight_; + + renderScale_ = 1.0f; + visibleLeft_ = 0.0f; + visibleTop_ = 0.0f; + visibleWidth_ = static_cast(width_); + visibleHeight_ = static_cast(height_); for (size_t i = 0; i < pagesData.size(); ++i) { SkCanvas* canvas = exportSurface->getCanvas(); @@ -408,7 +798,7 @@ std::vector SkiaDrawingEngine::batchExportPages( normalizeStrokeColorsForRasterExport(strokes_); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); needsEraserMaskRedraw_ = true; render(canvas); canvas->restore(); @@ -436,7 +826,12 @@ std::vector SkiaDrawingEngine::batchExportPages( backgroundOriginY_ = originalBackgroundOriginY; undoStack_ = std::move(originalUndoStack); redoStack_ = std::move(originalRedoStack); - needsStrokeRedraw_ = true; + renderScale_ = originalRenderScale; + visibleLeft_ = originalVisibleLeft; + visibleTop_ = originalVisibleTop; + visibleWidth_ = originalVisibleWidth; + visibleHeight_ = originalVisibleHeight; + markStrokeCachesDirty(); needsEraserMaskRedraw_ = true; return results; diff --git a/cpp/SkiaDrawingEngineSelection.cpp b/cpp/SkiaDrawingEngineSelection.cpp index dfa2a49..577e463 100644 --- a/cpp/SkiaDrawingEngineSelection.cpp +++ b/cpp/SkiaDrawingEngineSelection.cpp @@ -149,7 +149,7 @@ void SkiaDrawingEngine::deleteSelection() { auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; selection_->deleteSelection(strokes_, selectedIndices_, commit); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } void SkiaDrawingEngine::copySelection() { @@ -163,7 +163,7 @@ void SkiaDrawingEngine::pasteSelection(float offsetX, float offsetY) { auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; selection_->pasteSelection(strokes_, copiedStrokes_, offsetX, offsetY, commit); - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } void SkiaDrawingEngine::moveSelection(float dx, float dy) { @@ -201,7 +201,7 @@ void SkiaDrawingEngine::finalizeMove() { selection_->finalizeMove(strokes_, selectedIndices_, totalDx, totalDy, commit); endSelectionDrag(); // Now rebuild stroke surface with all strokes at final positions - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } void SkiaDrawingEngine::beginSelectionTransform(int handleIndex) { @@ -286,7 +286,7 @@ void SkiaDrawingEngine::updateSelectionTransform(float x, float y) { } selectionTransformHasDelta_ = true; - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } void SkiaDrawingEngine::finalizeSelectionTransform() { @@ -313,7 +313,7 @@ void SkiaDrawingEngine::finalizeSelectionTransform() { selectionTransformHandleIndex_ = -1; selectionTransformHasDelta_ = false; isTransformingSelection_ = false; - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } void SkiaDrawingEngine::cancelSelectionTransform() { @@ -333,7 +333,7 @@ void SkiaDrawingEngine::cancelSelectionTransform() { selectionTransformHandleIndex_ = -1; selectionTransformHasDelta_ = false; isTransformingSelection_ = false; - needsStrokeRedraw_ = true; + markStrokeCachesDirty(); } void SkiaDrawingEngine::prepareSelectionDragCache() { diff --git a/example/app.json b/example/app.json index 5e2d177..cb06315 100644 --- a/example/app.json +++ b/example/app.json @@ -12,6 +12,9 @@ "android": { "package": "io.mathnotes.mobileink.example" }, - "plugins": ["expo-dev-client"] + "plugins": [ + "expo-dev-client", + "./plugins/with-xcode-asset-symbol-workaround" + ] } } diff --git a/example/plugins/with-xcode-asset-symbol-workaround.js b/example/plugins/with-xcode-asset-symbol-workaround.js new file mode 100644 index 0000000..1a69996 --- /dev/null +++ b/example/plugins/with-xcode-asset-symbol-workaround.js @@ -0,0 +1,29 @@ +const { withXcodeProject } = require("expo/config-plugins"); + +const XCODE_LINK_WORKAROUNDS = { + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS: "NO", + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: "NO", + ENABLE_DEBUG_DYLIB: "NO", +}; + +module.exports = function withXcodeAssetSymbolWorkaround(config) { + return withXcodeProject(config, (config) => { + const buildConfigurations = config.modResults.pbxXCBuildConfigurationSection(); + + for (const buildConfiguration of Object.values(buildConfigurations)) { + if (!buildConfiguration || typeof buildConfiguration !== "object") { + continue; + } + + if (!buildConfiguration.buildSettings) { + continue; + } + + for (const [setting, value] of Object.entries(XCODE_LINK_WORKAROUNDS)) { + buildConfiguration.buildSettings[setting] = value; + } + } + + return config; + }); +}; diff --git a/ios/MobileInkModule/MobileInkBridge.mm b/ios/MobileInkModule/MobileInkBridge.mm index cff23dd..4f90bab 100644 --- a/ios/MobileInkModule/MobileInkBridge.mm +++ b/ios/MobileInkModule/MobileInkBridge.mm @@ -353,6 +353,25 @@ void renderToCanvas(void* engine, void* canvas) { } } +void setRenderViewport( + void* engine, + float renderScale, + float visibleLeft, + float visibleTop, + float visibleWidth, + float visibleHeight +) { + if (engine) { + static_cast(engine)->setRenderViewport( + renderScale, + visibleLeft, + visibleTop, + visibleWidth, + visibleHeight + ); + } +} + void* createGaneshMetalContext(void* devicePtr, void* commandQueuePtr) { if (!devicePtr || !commandQueuePtr) { return nullptr; diff --git a/ios/MobileInkModule/MobileInkCanvasView.swift b/ios/MobileInkModule/MobileInkCanvasView.swift index 2b58d09..0d5755b 100644 --- a/ios/MobileInkModule/MobileInkCanvasView.swift +++ b/ios/MobileInkModule/MobileInkCanvasView.swift @@ -69,11 +69,15 @@ class MobileInkCanvasView: MTKView { var scaleY: CGFloat = 1.0 private var pixelBuffer: UnsafeMutablePointer? private var pixelBufferLength: Int = 0 - private var pixelBytesPerRow: Int = 0 + private var pixelBytesPerRow: Int = 0 var pixelWidth: Int32 = 0 var pixelHeight: Int32 = 0 private var enginePixelWidth: Int32 = 0 private var enginePixelHeight: Int32 = 0 + private var isApplyingDrawableSize = false + private var clampedRenderScale: CGFloat { + max(1.0, min(5.0, renderScale.isFinite ? renderScale : 1.0)) + } // Store pending tool settings to apply when engine is ready private var pendingTool: String = "pen" @@ -135,6 +139,25 @@ class MobileInkCanvasView: MTKView { } } } + @objc var renderScale: CGFloat = 1.0 { + didSet { + applyDrawableContentScale(updateDrawableSize: true) + applyRenderViewportToEngine() + requestDisplay(forceWhenSuspended: true) + } + } + @objc var renderViewportLeftRatio: CGFloat = 0.0 { + didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + } + @objc var renderViewportTopRatio: CGFloat = 0.0 { + didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + } + @objc var renderViewportWidthRatio: CGFloat = 1.0 { + didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + } + @objc var renderViewportHeightRatio: CGFloat = 1.0 { + didSet { applyRenderViewportToEngine(); requestDisplay(forceWhenSuspended: true) } + } @objc var onDrawingChange: RCTDirectEventBlock? @objc var onDrawingBegin: RCTDirectEventBlock? @@ -175,8 +198,7 @@ class MobileInkCanvasView: MTKView { self.colorPixelFormat = .bgra8Unorm self.clearColor = MTLClearColorMake(0, 0, 0, 0) // Transparent - // Disable automatic resize to have more control - self.autoResizeDrawable = true + self.autoResizeDrawable = false // Configure Metal layer for transparency and low latency if let metalLayer = self.layer as? CAMetalLayer { @@ -341,9 +363,68 @@ class MobileInkCanvasView: MTKView { override func layoutSubviews() { super.layoutSubviews() + applyDrawableContentScale(updateDrawableSize: true) + applyRenderViewportToEngine() updateSelectionToolbarFrame() } + private func baseContentScale() -> CGFloat { + window?.screen.scale ?? UIScreen.main.scale + } + + private func applyDrawableContentScale(updateDrawableSize: Bool) { + let targetScale = baseContentScale() * clampedRenderScale + if abs(contentScaleFactor - targetScale) > 0.001 { + contentScaleFactor = targetScale + } + if abs(layer.contentsScale - targetScale) > 0.001 { + layer.contentsScale = targetScale + } + guard updateDrawableSize, !isApplyingDrawableSize, bounds.width > 0, bounds.height > 0 else { + return + } + + let targetDrawableSize = CGSize( + width: max(1.0, round(bounds.width * targetScale)), + height: max(1.0, round(bounds.height * targetScale)) + ) + if abs(drawableSize.width - targetDrawableSize.width) > 0.5 || + abs(drawableSize.height - targetDrawableSize.height) > 0.5 { + isApplyingDrawableSize = true + defer { isApplyingDrawableSize = false } + drawableSize = targetDrawableSize + } + } + + private func applyRenderViewportToEngine() { + guard let engine = drawingEngine, enginePixelWidth > 0, enginePixelHeight > 0 else { + return + } + + let actualScaleX = pixelWidth > 0 + ? CGFloat(pixelWidth) / CGFloat(enginePixelWidth) + : clampedRenderScale + let actualRenderScale = max( + 1.0, + min(5.0, actualScaleX.isFinite ? actualScaleX : 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)) + let heightRatio = max(0.001, min(1.0, renderViewportHeightRatio)) + let width = min(CGFloat(enginePixelWidth) - left, widthRatio * CGFloat(enginePixelWidth)) + let height = min(CGFloat(enginePixelHeight) - top, heightRatio * CGFloat(enginePixelHeight)) + + setRenderViewport( + engine, + Float(actualRenderScale), + Float(left), + Float(top), + Float(max(1.0, width)), + Float(max(1.0, height)) + ) + } + func requestDisplay(forceWhenSuspended: Bool = false) { if renderSuspended && !forceWhenSuspended { return @@ -1504,6 +1585,7 @@ class MobileInkCanvasView: MTKView { } applyPendingBackgroundType(to: engine) + applyRenderViewportToEngine() if let preservedDrawingData, !preservedDrawingData.isEmpty { if !restoreRawDrawingData(preservedDrawingData, into: engine) { @@ -1518,6 +1600,7 @@ class MobileInkCanvasView: MTKView { resetTransientInteractionState() applyPendingTool(to: engine) notifySelectionChange() + applyRenderViewportToEngine() requestDisplay(forceWhenSuspended: true) } } @@ -1599,10 +1682,22 @@ extension MobileInkCanvasView: MTKViewDelegate { pageHeight = pageWidth * (11.0 / 8.5) // US Letter aspect ratio } - // Calculate scale from view bounds to drawable (pixel) coordinates for touch input + applyDrawableContentScale(updateDrawableSize: false) + + // Calculate scale from view bounds to the stable document-pixel + // coordinate system. The drawable may be larger at high zoom, but + // touches must remain in the same persisted stroke coordinates. + let baseScale = baseContentScale() + let nextEnginePixelWidth: Int32 + let nextEnginePixelHeight: Int32 if bounds.width > 0 && bounds.height > 0 { - scaleX = size.width / bounds.width - scaleY = size.height / bounds.height + scaleX = baseScale + scaleY = baseScale + nextEnginePixelWidth = Int32(max(1, round(bounds.width * baseScale))) + nextEnginePixelHeight = Int32(max(1, round(bounds.height * baseScale))) + } else { + nextEnginePixelWidth = 0 + nextEnginePixelHeight = 0 } if size.width > 0 && size.height > 0 { @@ -1618,21 +1713,23 @@ extension MobileInkCanvasView: MTKViewDelegate { } } - if pixelWidth > 0 && pixelHeight > 0 { + if nextEnginePixelWidth > 0 && nextEnginePixelHeight > 0 { if drawingEngine == nil { configureDrawingEngine( - width: pixelWidth, - height: pixelHeight, + width: nextEnginePixelWidth, + height: nextEnginePixelHeight, preserveExistingDrawing: false ) print("✅ Drawing engine created with size: \(pixelWidth)x\(pixelHeight) (page: \(pageWidth)x\(pageHeight))") - } else if enginePixelWidth != pixelWidth || enginePixelHeight != pixelHeight { + } else if enginePixelWidth != nextEnginePixelWidth || enginePixelHeight != nextEnginePixelHeight { configureDrawingEngine( - width: pixelWidth, - height: pixelHeight, + width: nextEnginePixelWidth, + height: nextEnginePixelHeight, preserveExistingDrawing: true ) print("✅ Drawing engine resized to: \(pixelWidth)x\(pixelHeight) (page: \(pageWidth)x\(pageHeight))") + } else { + applyRenderViewportToEngine() } } diff --git a/ios/MobileInkModule/MobileInkCanvasViewManager.m b/ios/MobileInkModule/MobileInkCanvasViewManager.m index 4ef55ef..5cdee9f 100644 --- a/ios/MobileInkModule/MobileInkCanvasViewManager.m +++ b/ios/MobileInkModule/MobileInkCanvasViewManager.m @@ -12,6 +12,11 @@ @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(renderScale, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(renderViewportLeftRatio, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(renderViewportTopRatio, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(renderViewportWidthRatio, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(renderViewportHeightRatio, CGFloat) RCT_EXTERN_METHOD(clear:(nonnull NSNumber *)node) RCT_EXTERN_METHOD(undo:(nonnull NSNumber *)node) diff --git a/ios/MobileInkModule/MobileInkCanvasViewManager.swift b/ios/MobileInkModule/MobileInkCanvasViewManager.swift index f540d28..64c8ac7 100644 --- a/ios/MobileInkModule/MobileInkCanvasViewManager.swift +++ b/ios/MobileInkModule/MobileInkCanvasViewManager.swift @@ -89,6 +89,28 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { } } + @objc var renderScale: CGFloat = 1.0 { + didSet { + drawingView?.renderScale = renderScale + } + } + + @objc var renderViewportLeftRatio: CGFloat = 0.0 { + didSet { drawingView?.renderViewportLeftRatio = renderViewportLeftRatio } + } + + @objc var renderViewportTopRatio: CGFloat = 0.0 { + didSet { drawingView?.renderViewportTopRatio = renderViewportTopRatio } + } + + @objc var renderViewportWidthRatio: CGFloat = 1.0 { + didSet { drawingView?.renderViewportWidthRatio = renderViewportWidthRatio } + } + + @objc var renderViewportHeightRatio: CGFloat = 1.0 { + didSet { drawingView?.renderViewportHeightRatio = renderViewportHeightRatio } + } + // Drawing policy - controls whether fingers or only Apple Pencil can draw @objc var drawingPolicy: String? { didSet { @@ -124,6 +146,11 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { if let backend = renderBackend { view.renderBackend = backend } + view.renderScale = renderScale + view.renderViewportLeftRatio = renderViewportLeftRatio + view.renderViewportTopRatio = renderViewportTopRatio + view.renderViewportWidthRatio = renderViewportWidthRatio + view.renderViewportHeightRatio = renderViewportHeightRatio view.renderSuspended = renderSuspended } diff --git a/ios/MobileInkModule/SkiaEngineBridge.swift b/ios/MobileInkModule/SkiaEngineBridge.swift index 34f6df4..aafd71d 100644 --- a/ios/MobileInkModule/SkiaEngineBridge.swift +++ b/ios/MobileInkModule/SkiaEngineBridge.swift @@ -70,6 +70,16 @@ func isEmpty(_ engine: OpaquePointer) -> Bool @_silgen_name("renderToCanvas") func renderToCanvas(_ engine: OpaquePointer, _ canvas: OpaquePointer) +@_silgen_name("setRenderViewport") +func setRenderViewport( + _ engine: OpaquePointer, + _ renderScale: Float, + _ visibleLeft: Float, + _ visibleTop: Float, + _ visibleWidth: Float, + _ visibleHeight: Float +) + @_silgen_name("createGaneshMetalContext") func createGaneshMetalContext(_ device: UnsafeMutableRawPointer, _ commandQueue: UnsafeMutableRawPointer) -> OpaquePointer? diff --git a/src/ContinuousEnginePool.tsx b/src/ContinuousEnginePool.tsx index 09555b7..a3b5c67 100644 --- a/src/ContinuousEnginePool.tsx +++ b/src/ContinuousEnginePool.tsx @@ -35,7 +35,11 @@ export const ContinuousEnginePool = memo(forwardRef< ContinuousEnginePoolRef, ContinuousEnginePoolProps >(function ContinuousEnginePool({ + pageWidth, canvasHeight, + contentPadding, + viewportTransform, + renderScale, backgroundType, renderBackend = DEFAULT_NATIVE_INK_RENDER_BACKEND, pdfBackgroundBaseUri, @@ -188,8 +192,12 @@ export const ContinuousEnginePool = memo(forwardRef< }, [ backgroundType, canvasHeight, + contentPadding, + pageWidth, pdfBackgroundBaseUri, + renderScale, startSlotBenchmarkRecording, + viewportTransform, ]); const applyToolState = useCallback((toolState: ContinuousEnginePoolToolState) => { @@ -268,7 +276,11 @@ export const ContinuousEnginePool = memo(forwardRef< key={`continuous-engine-pool-${poolIndex}`} ref={(slotRef) => handleSlotRef(poolIndex, slotRef)} poolIndex={poolIndex} + pageWidth={pageWidth} canvasHeight={canvasHeight} + contentPadding={contentPadding} + viewportTransform={viewportTransform} + renderScale={renderScale} backgroundType={backgroundType} renderBackend={renderBackend} pdfBackgroundBaseUri={pdfBackgroundBaseUri} @@ -288,7 +300,11 @@ export const ContinuousEnginePool = memo(forwardRef< ); }), (prev, next) => ( + prev.pageWidth === next.pageWidth && prev.canvasHeight === next.canvasHeight && + prev.contentPadding === next.contentPadding && + prev.viewportTransform === next.viewportTransform && + prev.renderScale === next.renderScale && prev.backgroundType === next.backgroundType && prev.renderBackend === next.renderBackend && prev.pdfBackgroundBaseUri === next.pdfBackgroundBaseUri && diff --git a/src/InfiniteInkCanvas.tsx b/src/InfiniteInkCanvas.tsx index 1883bcb..6747f3a 100644 --- a/src/InfiniteInkCanvas.tsx +++ b/src/InfiniteInkCanvas.tsx @@ -6,7 +6,7 @@ import React, { useRef, useState, } from "react"; -import { View } from "react-native"; +import { PixelRatio, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ContinuousEnginePool } from "./ContinuousEnginePool"; import type { @@ -48,6 +48,10 @@ import { BLANK_PAGE_PAYLOAD, withSingleTrailingBlankPage, } from "./utils/pageGrowth"; +import { + getNativeRenderScaleBudget, + quantizeNativeRenderScale, +} from "./utils/nativeRenderScale"; export type { InfiniteInkCanvasProps, @@ -110,6 +114,9 @@ function InfiniteInkCanvasImpl( const [pages, setPages] = useState(() => createInitialPages(initialData, initialPageCount), ); + const [nativeViewportTransform, setNativeViewportTransform] = + useState(null); + const [nativeRenderScale, setNativeRenderScale] = useState(1); const pagesRef = useRef(pages); const currentPageIndexRef = useRef(0); const toolStateRef = useRef(toolState); @@ -203,12 +210,36 @@ function InfiniteInkCanvasImpl( ); }, [commitViewportPage, contentPadding, pageHeight]); + const updateNativeRenderViewport = useCallback((transform: InfiniteInkViewportTransform) => { + const nextRenderScale = quantizeNativeRenderScale( + transform.scale, + getNativeRenderScaleBudget(pageWidth, pageHeight, PixelRatio.get()), + ); + setNativeRenderScale((previousScale) => ( + previousScale === nextRenderScale ? previousScale : nextRenderScale + )); + setNativeViewportTransform((previousTransform) => { + if ( + previousTransform && + previousTransform.scale === transform.scale && + previousTransform.translateX === transform.translateX && + previousTransform.translateY === transform.translateY && + previousTransform.containerWidth === transform.containerWidth && + previousTransform.containerHeight === transform.containerHeight + ) { + return previousTransform; + } + return transform; + }); + }, [pageHeight, pageWidth]); + const handleTransformChange = useCallback((transform: InfiniteInkViewportTransform) => { latestTransformRef.current = transform; if (isMovingRef.current) { return; } + updateNativeRenderViewport(transform); commitViewportPage( getVisiblePageIndex( transform, @@ -217,15 +248,20 @@ function InfiniteInkCanvasImpl( contentPadding, ), ); - }, [commitViewportPage, contentPadding, pageHeight]); + }, [commitViewportPage, contentPadding, pageHeight, updateNativeRenderViewport]); const handleMotionStateChange = useCallback((isMoving: boolean) => { isMovingRef.current = isMoving; onMotionStateChange?.(isMoving); if (!isMoving) { + const transform = viewportRef.current?.getTransform() ?? latestTransformRef.current; + if (transform) { + latestTransformRef.current = transform; + updateNativeRenderViewport(transform); + } commitLatestViewportPage(); } - }, [commitLatestViewportPage, onMotionStateChange]); + }, [commitLatestViewportPage, onMotionStateChange, updateNativeRenderViewport]); const registerPerPageSlot = useCallback(( pageId: string, @@ -512,7 +548,11 @@ function InfiniteInkCanvasImpl( /> { ); }); + it("forwards center-origin visible viewport ratios to native canvases", async () => { + const pages = [page(0), page(1), page(2)]; + const { poolRef } = renderPool({ + viewportTransform: { + scale: 2, + translateX: 0, + translateY: 0, + containerWidth: 400, + containerHeight: 400, + }, + renderScale: 2, + }); + + await act(async () => {}); + await assignPages(poolRef, buildAssignments(pages, 0)); + + const [firstSlotProps] = mockNativeCanvasProps.slice(-3); + expect(firstSlotProps).toEqual(expect.objectContaining({ + renderScale: 2, + renderViewportLeftRatio: 100 / 600, + renderViewportTopRatio: 100 / 800, + renderViewportWidthRatio: 200 / 600, + renderViewportHeightRatio: 200 / 800, + })); + }); + it("forwards pencil double-tap events to every pooled native canvas", async () => { const onPencilDoubleTap = jest.fn(); diff --git a/src/continuous-engine-pool/PooledCanvasSlot.tsx b/src/continuous-engine-pool/PooledCanvasSlot.tsx index d3d712f..89d865f 100644 --- a/src/continuous-engine-pool/PooledCanvasSlot.tsx +++ b/src/continuous-engine-pool/PooledCanvasSlot.tsx @@ -19,6 +19,7 @@ import { OFFSCREEN_TOP, waitForNextFrame, } from "./helpers"; +import { getVisibleContentRect } from "../utils/viewportTransform"; import type { ContinuousEnginePoolAssignment, ContinuousEnginePoolSlotRef, @@ -40,6 +41,10 @@ type SlotFrame = { const PAGE_PREVIEW_CAPTURE_SCALE = 0.25; +const clamp = (value: number, min: number, max: number) => ( + Math.max(min, Math.min(max, value)) +); + const hiddenSlotFrame = (height = 0): SlotFrame => ({ top: OFFSCREEN_TOP, height, @@ -50,7 +55,11 @@ const hiddenSlotFrame = (height = 0): SlotFrame => ({ export const PooledCanvasSlot = memo(forwardRef( function PooledCanvasSlot({ poolIndex, + pageWidth, canvasHeight, + contentPadding, + viewportTransform, + renderScale, backgroundType, renderBackend, pdfBackgroundBaseUri, @@ -158,6 +167,48 @@ export const PooledCanvasSlot = memo(forwardRef { + const pageIndex = slotFrame.top >= 0 && canvasHeight > 0 + ? Math.round(slotFrame.top / canvasHeight) + : null; + + if (!viewportTransform || pageIndex === null || pageWidth <= 0 || canvasHeight <= 0) { + return { + leftRatio: 0, + topRatio: 0, + widthRatio: 1, + heightRatio: 1, + intersectsViewport: true, + }; + } + + const visibleContentRect = getVisibleContentRect(viewportTransform); + const visibleLeft = visibleContentRect.left; + const visibleTop = visibleContentRect.top - contentPadding; + const visibleRight = visibleContentRect.right; + const visibleBottom = visibleContentRect.bottom - contentPadding; + const pageTop = pageIndex * canvasHeight; + const localLeft = clamp(visibleLeft, 0, pageWidth); + const localTop = clamp(visibleTop - pageTop, 0, canvasHeight); + const localRight = clamp(visibleRight, 0, pageWidth); + const localBottom = clamp(visibleBottom - pageTop, 0, canvasHeight); + const intersectsViewport = localRight > 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, + }; + }, [ + canvasHeight, + contentPadding, + pageWidth, + slotFrame.top, + viewportTransform, + ]); + const slotRef = useMemo( () => ({ getBase64Data: async () => { @@ -597,6 +648,11 @@ export const PooledCanvasSlot = memo(forwardRef ( prev.poolIndex === next.poolIndex && + prev.pageWidth === next.pageWidth && prev.canvasHeight === next.canvasHeight && + prev.contentPadding === next.contentPadding && + prev.viewportTransform === next.viewportTransform && + prev.renderScale === next.renderScale && prev.backgroundType === next.backgroundType && prev.renderBackend === next.renderBackend && prev.pdfBackgroundBaseUri === next.pdfBackgroundBaseUri && diff --git a/src/continuous-engine-pool/types.ts b/src/continuous-engine-pool/types.ts index 65ef308..05fb356 100644 --- a/src/continuous-engine-pool/types.ts +++ b/src/continuous-engine-pool/types.ts @@ -52,7 +52,17 @@ export type ContinuousEnginePoolRef = { }; export type ContinuousEnginePoolProps = { + pageWidth: number; canvasHeight: number; + contentPadding: number; + viewportTransform: { + scale: number; + translateX: number; + translateY: number; + containerWidth: number; + containerHeight: number; + } | null; + renderScale: number; backgroundType: string; renderBackend?: NativeInkRenderBackend; pdfBackgroundBaseUri: string | undefined; @@ -104,7 +114,11 @@ export type PooledCanvasSlotHandle = { export type PooledCanvasSlotProps = { poolIndex: number; + pageWidth: number; canvasHeight: number; + contentPadding: number; + viewportTransform: ContinuousEnginePoolProps["viewportTransform"]; + renderScale: number; backgroundType: string; renderBackend?: NativeInkRenderBackend; pdfBackgroundBaseUri?: string; diff --git a/src/infinite-ink-canvas/notebookPages.ts b/src/infinite-ink-canvas/notebookPages.ts index fb9f3b1..a3c7fb3 100644 --- a/src/infinite-ink-canvas/notebookPages.ts +++ b/src/infinite-ink-canvas/notebookPages.ts @@ -1,4 +1,5 @@ import type { NotebookPage, SerializedNotebookData } from "../types"; +import { getVisibleContentRect } from "../utils/viewportTransform"; import { BLANK_PAGE_PAYLOAD, createBlankPage, @@ -53,9 +54,9 @@ export const getVisiblePageIndex = ( pageCount: number, contentPadding: number, ) => { - const scale = Math.max(transform.scale, 0.0001); - const visibleTopY = Math.max(0, (-transform.translateY) / scale - contentPadding); - const visibleCenterY = visibleTopY + transform.containerHeight / (2 * scale); + const visibleRect = getVisibleContentRect(transform); + const visibleTopY = Math.max(0, visibleRect.top - contentPadding); + const visibleCenterY = visibleTopY + visibleRect.height / 2; return Math.max( 0, Math.min(pageCount - 1, Math.floor(visibleCenterY / pageHeight)), diff --git a/src/native-ink-canvas/types.ts b/src/native-ink-canvas/types.ts index 35b7a04..755ca7a 100644 --- a/src/native-ink-canvas/types.ts +++ b/src/native-ink-canvas/types.ts @@ -18,6 +18,13 @@ export interface NativeInkCanvasProps { renderSuspended?: boolean; /** iOS only: Chooses the native render path for A/B performance tests. */ renderBackend?: NativeInkRenderBackend; + /** Native raster scale bucket used to keep ink sharp after zoom settles. */ + renderScale?: number; + /** Visible page rect, expressed as normalized page-space ratios. */ + renderViewportLeftRatio?: number; + renderViewportTopRatio?: number; + renderViewportWidthRatio?: number; + renderViewportHeightRatio?: number; /** iOS only: Controls whether fingers or only Apple Pencil can draw */ drawingPolicy?: "default" | "anyinput" | "pencilonly"; /** iOS only: Fired when Apple Pencil barrel is double-tapped (2nd gen+) */ diff --git a/src/utils/__tests__/nativeRenderScale.test.ts b/src/utils/__tests__/nativeRenderScale.test.ts new file mode 100644 index 0000000..366bb8c --- /dev/null +++ b/src/utils/__tests__/nativeRenderScale.test.ts @@ -0,0 +1,24 @@ +import { + getNativeRenderScaleBudget, + quantizeNativeRenderScale, +} from "../nativeRenderScale"; + +describe("native render scale budgeting", () => { + it("caps default iPad page render scale to the 2.5x bucket", () => { + const budget = getNativeRenderScaleBudget(820, 1061, 2); + + expect(budget).toBeGreaterThan(2.5); + expect(budget).toBeLessThan(3); + expect(quantizeNativeRenderScale(5, budget)).toBe(2.5); + }); + + it("never chooses a bucket above the requested zoom or pixel budget", () => { + expect(quantizeNativeRenderScale(2.4, 5)).toBe(2); + expect(quantizeNativeRenderScale(5, 2.2)).toBe(2); + }); + + it("falls back to base scale for invalid page geometry", () => { + expect(getNativeRenderScaleBudget(0, 1061, 2)).toBe(1); + expect(quantizeNativeRenderScale(Number.NaN, 5)).toBe(1); + }); +}); diff --git a/src/utils/__tests__/viewportTransform.test.ts b/src/utils/__tests__/viewportTransform.test.ts new file mode 100644 index 0000000..5e96c7d --- /dev/null +++ b/src/utils/__tests__/viewportTransform.test.ts @@ -0,0 +1,37 @@ +import { getVisibleContentRect } from "../viewportTransform"; + +describe("getVisibleContentRect", () => { + it("matches top-left translation at 1x scale", () => { + expect(getVisibleContentRect({ + scale: 1, + translateX: -40, + translateY: -120, + containerWidth: 600, + containerHeight: 800, + })).toEqual({ + left: 40, + top: 120, + right: 640, + bottom: 920, + width: 600, + height: 800, + }); + }); + + it("accounts for the viewport-center transform origin while zoomed", () => { + expect(getVisibleContentRect({ + scale: 2, + translateX: 0, + translateY: 0, + containerWidth: 400, + containerHeight: 400, + })).toEqual({ + left: 100, + top: 100, + right: 300, + bottom: 300, + width: 200, + height: 200, + }); + }); +}); diff --git a/src/utils/nativeRenderScale.ts b/src/utils/nativeRenderScale.ts new file mode 100644 index 0000000..c0087cb --- /dev/null +++ b/src/utils/nativeRenderScale.ts @@ -0,0 +1,34 @@ +const NATIVE_RENDER_SCALE_BUCKETS = [1, 1.5, 2, 2.5, 3, 4, 5] as const; + +export const MAX_NATIVE_DRAWABLE_PIXELS = 24_000_000; + +export const getNativeRenderScaleBudget = ( + pageWidth: number, + pageHeight: number, + deviceScale: number, +) => { + const basePixelArea = pageWidth * pageHeight * deviceScale * deviceScale; + if (!Number.isFinite(basePixelArea) || basePixelArea <= 0) { + return 1; + } + + return Math.max(1, Math.min(5, Math.sqrt(MAX_NATIVE_DRAWABLE_PIXELS / basePixelArea))); +}; + +export const quantizeNativeRenderScale = (scale: number, maxScale: number) => { + const targetScale = Math.max( + 1, + Math.min( + Number.isFinite(maxScale) ? maxScale : 1, + Number.isFinite(scale) ? scale : 1, + ), + ); + let selected: number = NATIVE_RENDER_SCALE_BUCKETS[0]; + for (const bucket of NATIVE_RENDER_SCALE_BUCKETS) { + if (bucket <= targetScale + 0.001) { + selected = bucket; + } + } + + return selected; +}; diff --git a/src/utils/viewportTransform.ts b/src/utils/viewportTransform.ts new file mode 100644 index 0000000..869033e --- /dev/null +++ b/src/utils/viewportTransform.ts @@ -0,0 +1,51 @@ +export type ViewportTransformLike = { + scale: number; + translateX: number; + translateY: number; + containerWidth: number; + containerHeight: number; +}; + +export type VisibleContentRect = { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +}; + +const toFiniteNumber = (value: number, fallback: number) => ( + Number.isFinite(value) ? value : fallback +); + +export const getVisibleContentRect = ( + transform: ViewportTransformLike, +): VisibleContentRect => { + const scale = Math.max(0.0001, toFiniteNumber(transform.scale, 1)); + const containerWidth = Math.max(0, toFiniteNumber(transform.containerWidth, 0)); + const containerHeight = Math.max(0, toFiniteNumber(transform.containerHeight, 0)); + const translateX = toFiniteNumber(transform.translateX, 0); + const translateY = toFiniteNumber(transform.translateY, 0); + const originX = containerWidth / 2; + const originY = containerHeight / 2; + + const left = originX + ((0 - originX - translateX) / scale); + const top = originY + ((0 - originY - translateY) / scale); + const right = originX + ((containerWidth - originX - translateX) / scale); + const bottom = originY + ((containerHeight - originY - translateY) / scale); + + const normalizedLeft = Math.min(left, right); + const normalizedTop = Math.min(top, bottom); + const normalizedRight = Math.max(left, right); + const normalizedBottom = Math.max(top, bottom); + + return { + left: normalizedLeft, + top: normalizedTop, + right: normalizedRight, + bottom: normalizedBottom, + width: Math.max(0, normalizedRight - normalizedLeft), + height: Math.max(0, normalizedBottom - normalizedTop), + }; +}; diff --git a/src/zoomable-ink-viewport/useZoomableViewportGestures.ts b/src/zoomable-ink-viewport/useZoomableViewportGestures.ts index a621279..eaa5abc 100644 --- a/src/zoomable-ink-viewport/useZoomableViewportGestures.ts +++ b/src/zoomable-ink-viewport/useZoomableViewportGestures.ts @@ -186,6 +186,18 @@ export const useZoomableViewportGestures = ({ savedTranslateY.value = translateY.value; }; + const flushTransformChange = () => { + "worklet"; + lastTransformNotificationTs.value = Date.now(); + runOnJS(notifyTransformChange)( + scale.value, + translateX.value, + translateY.value, + containerWidth.value, + containerHeight.value + ); + }; + const syncMotionState = () => { "worklet"; const nextIsMoving = @@ -200,6 +212,9 @@ export const useZoomableViewportGestures = ({ } isMotionActive.value = nextIsMoving; + if (!nextIsMoving) { + flushTransformChange(); + } runOnJS(notifyMotionChange)(nextIsMoving); }; @@ -381,7 +396,7 @@ export const useZoomableViewportGestures = ({ syncMotionState(); } }); - }, [enabled, minScale, maxScale, onZoomChange, notifyGestureStart, notifyGestureEnd, notifyMotionChange]); + }, [enabled, minScale, maxScale, onZoomChange, notifyGestureStart, notifyGestureEnd, notifyMotionChange, notifyTransformChange]); const panGesture = useMemo(() => { const gesture = Gesture.Pan() @@ -615,7 +630,7 @@ export const useZoomableViewportGestures = ({ }); return gesture; - }, [enableMomentumScroll, enabled, fingerDrawingEnabled, edgeExclusionWidth, notifyGestureStart, notifyGestureEnd, notifyMotionChange, panEnabled]); + }, [enableMomentumScroll, enabled, fingerDrawingEnabled, edgeExclusionWidth, notifyGestureStart, notifyGestureEnd, notifyMotionChange, notifyTransformChange, panEnabled]); const tapGesture = useMemo(() => { return Gesture.Tap()