From 7d42e512487858af4aacd9a98e6994172c1322ee Mon Sep 17 00:00:00 2001 From: markm39 Date: Thu, 14 May 2026 08:23:24 -0500 Subject: [PATCH] refactor(ink): split large JS modules --- package.json | 3 + src/ContinuousEnginePool.tsx | 710 +----------------- src/NativeInkCanvas.tsx | 316 +------- src/ZoomableInkViewport.tsx | 95 +-- .../PooledCanvasSlot.tsx | 554 ++++++++++++++ src/continuous-engine-pool/helpers.ts | 35 + src/continuous-engine-pool/types.ts | 132 ++++ src/native-ink-canvas/nativeModules.ts | 51 ++ src/native-ink-canvas/notebookBridge.ts | 153 ++++ src/native-ink-canvas/types.ts | 103 +++ src/zoomable-ink-viewport/types.ts | 82 ++ 11 files changed, 1165 insertions(+), 1069 deletions(-) create mode 100644 src/continuous-engine-pool/PooledCanvasSlot.tsx create mode 100644 src/continuous-engine-pool/helpers.ts create mode 100644 src/continuous-engine-pool/types.ts create mode 100644 src/native-ink-canvas/nativeModules.ts create mode 100644 src/native-ink-canvas/notebookBridge.ts create mode 100644 src/native-ink-canvas/types.ts create mode 100644 src/zoomable-ink-viewport/types.ts diff --git a/package.json b/package.json index 0e9c64a..59a1c23 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,14 @@ "types": "./lib/typescript/commonjs/index.d.ts", "files": [ "lib", + "src/continuous-engine-pool", "src/ContinuousEnginePool.tsx", "src/InfiniteInkCanvas.tsx", + "src/native-ink-canvas", "src/NativeInkCanvas.tsx", "src/NativeInkPageBackground.tsx", "src/ZoomableInkViewport.tsx", + "src/zoomable-ink-viewport", "src/index.ts", "src/payload.ts", "src/types.ts", diff --git a/src/ContinuousEnginePool.tsx b/src/ContinuousEnginePool.tsx index fb7e814..09555b7 100644 --- a/src/ContinuousEnginePool.tsx +++ b/src/ContinuousEnginePool.tsx @@ -2,694 +2,34 @@ import React, { forwardRef, memo, useCallback, - useEffect, useImperativeHandle, - useMemo, useRef, } from "react"; -import { StyleSheet, View } from "react-native"; -import { NativeInkCanvas } from "./NativeInkCanvas"; -import { - DEFAULT_NATIVE_INK_RENDER_BACKEND, -} from "./benchmark"; +import { DEFAULT_NATIVE_INK_RENDER_BACKEND } from "./benchmark"; import type { - NativeInkBenchmarkOptions, NativeInkBenchmarkRecordingOptions, NativeInkBenchmarkResult, - NativeInkRenderBackend, } from "./benchmark"; +import { + getAssignmentKey, +} from "./continuous-engine-pool/helpers"; +import { PooledCanvasSlot } from "./continuous-engine-pool/PooledCanvasSlot"; import type { - NativeInkCanvasProps, - NativeInkCanvasRef, -} from "./NativeInkCanvas"; -import type { NativeSelectionBounds } from "./types"; -import type { NotebookPage, ToolType } from "./types"; + ContinuousEnginePoolAssignment, + ContinuousEnginePoolProps, + ContinuousEnginePoolRef, + ContinuousEnginePoolToolState, + PooledCanvasSlotHandle, +} from "./continuous-engine-pool/types"; import { CONTINUOUS_ENGINE_POOL_SIZE } from "./utils/continuousEnginePool"; -const BLANK_PAGE_PAYLOAD = '{"pages":{}}'; -const OFFSCREEN_TOP = -100000; - -export type ContinuousEnginePoolAssignment = { - page: NotebookPage; - pageIndex: number; -}; - -export type ContinuousEnginePoolToolState = { - toolType: ToolType; - width: number; - color: string; - eraserMode: string; -}; - -export type ContinuousEnginePoolSlotRef = { - getBase64Data: () => Promise; - isLoaded: () => boolean; - setTool: ( - toolType: string, - width: number, - color: string, - eraserMode: string, - ) => void; - clear: () => void; - undo: () => void; - redo: () => void; - performCopy: () => void; - performPaste: () => void; - performDelete: () => void; - runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; - startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; - stopBenchmarkRecording?: () => Promise; -}; - -export type ContinuousEnginePoolRef = { - assignPages: (assignments: ContinuousEnginePoolAssignment[]) => Promise; - applyToolState: (toolState: ContinuousEnginePoolToolState) => void; - startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; - stopBenchmarkRecording: () => Promise; - release: () => Promise; -}; - -export type ContinuousEnginePoolProps = { - canvasHeight: number; - backgroundType: string; - renderBackend?: NativeInkRenderBackend; - pdfBackgroundBaseUri: string | undefined; - fingerDrawingEnabled: boolean; - getToolState: () => ContinuousEnginePoolToolState; - onCanvasReady: () => void; - onAssignmentReady?: (assignmentKey: string) => void; - onPageAssignmentReady?: ( - pageId: string, - pageIndex: number, - assignmentKey: string, - ) => void; - onPerPageDrawingChange: (pageId: string) => void; - onPerPageSelectionChange?: ( - pageId: string, - count: number, - bounds: NativeSelectionBounds | null, - ) => void; - onDrawingBegin?: () => void; - onPencilDoubleTap?: NativeInkCanvasProps["onPencilDoubleTap"]; - registerPerPageSlot: ( - pageId: string, - ref: ContinuousEnginePoolSlotRef | null, - sourceRef?: ContinuousEnginePoolSlotRef, - ) => void; - shouldCaptureBeforeReassign: (pageId: string) => boolean; - onSlotCaptureBeforeUnmount: (pageId: string, data: string) => void; -}; - -type SlotAssignOptions = { - assignment: ContinuousEnginePoolAssignment | null; - assignmentKey: string; - canvasHeight: number; - backgroundType: string; - pdfBackgroundBaseUri?: string; -}; - -type PooledCanvasSlotHandle = { - assign: (options: SlotAssignOptions) => Promise; - applyToolState: (toolState: ContinuousEnginePoolToolState) => void; - startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; - stopBenchmarkRecording: () => Promise; - release: () => Promise; -}; - -type PooledCanvasSlotProps = { - poolIndex: number; - canvasHeight: number; - backgroundType: string; - renderBackend?: NativeInkRenderBackend; - pdfBackgroundBaseUri?: string; - drawingPolicy: "anyinput" | "pencilonly"; - getToolState: () => ContinuousEnginePoolToolState; - onCanvasReady?: () => void; - onSlotLoaded: ( - poolIndex: number, - assignmentKey: string, - pageId: string, - pageIndex: number, - ) => void; - onDrawingChange: (pageId: string) => void; - onSelectionChange?: ( - pageId: string, - count: number, - bounds: NativeSelectionBounds | null, - ) => void; - onDrawingBegin?: () => void; - onPencilDoubleTap?: NativeInkCanvasProps["onPencilDoubleTap"]; - registerRef: ( - pageId: string, - ref: ContinuousEnginePoolSlotRef | null, - sourceRef?: ContinuousEnginePoolSlotRef, - ) => void; - shouldCaptureBeforeReassign: (pageId: string) => boolean; - onCaptureBeforeReassign: (pageId: string, data: string) => void; -}; - -const getAssignmentKey = (assignments: ContinuousEnginePoolAssignment[]) => { - return assignments - .map(({ page, pageIndex }) => [ - pageIndex, - page.id, - page.pdfPageNumber ?? "", - page.rotation ?? 0, - ].join(":")) - .join("|"); -}; - -const getPdfBackgroundUri = ( - assignment: ContinuousEnginePoolAssignment, - backgroundType: string, - pdfBackgroundBaseUri?: string, -) => { - if (backgroundType !== "pdf" || !pdfBackgroundBaseUri) { - return undefined; - } - - return `${pdfBackgroundBaseUri}#page=${ - assignment.page.pdfPageNumber || assignment.pageIndex + 1 - }`; -}; - -const waitForNextFrame = () => new Promise((resolve) => { - requestAnimationFrame(() => resolve()); -}); - -const PooledCanvasSlot = memo(forwardRef( - function PooledCanvasSlot({ - poolIndex, - canvasHeight, - backgroundType, - renderBackend, - pdfBackgroundBaseUri, - drawingPolicy, - getToolState, - onCanvasReady, - onSlotLoaded, - onDrawingChange, - onSelectionChange, - onDrawingBegin, - onPencilDoubleTap, - registerRef, - shouldCaptureBeforeReassign, - onCaptureBeforeReassign, - }, ref) { - const slotViewRef = useRef(null); - const canvasRef = useRef(null); - const lastAttachedCanvasRef = useRef(null); - const nativeReadyRef = useRef(false); - const nativeReadyWaitersRef = useRef void>>([]); - const isLoadedRef = useRef(false); - const loadedPageIdRef = useRef(null); - const registeredPageIdRef = useRef(null); - const currentAssignmentRef = useRef(null); - const assignmentKeyRef = useRef(""); - const loadTokenRef = useRef(0); - const forwardedReadyRef = useRef(false); - const benchmarkRecordingActiveRef = useRef(false); - const shouldCaptureBeforeReassignRef = useRef(shouldCaptureBeforeReassign); - const captureCallbackRef = useRef(onCaptureBeforeReassign); - const onCanvasReadyRef = useRef(onCanvasReady); - const onSlotLoadedRef = useRef(onSlotLoaded); - const onSelectionChangeRef = useRef(onSelectionChange); - const getToolStateRef = useRef(getToolState); - - shouldCaptureBeforeReassignRef.current = shouldCaptureBeforeReassign; - captureCallbackRef.current = onCaptureBeforeReassign; - onCanvasReadyRef.current = onCanvasReady; - onSlotLoadedRef.current = onSlotLoaded; - onSelectionChangeRef.current = onSelectionChange; - getToolStateRef.current = getToolState; - - const setNativeCanvasRef = useCallback((nextRef: NativeInkCanvasRef | null) => { - canvasRef.current = nextRef; - if (nextRef) { - lastAttachedCanvasRef.current = nextRef; - } - }, []); - - const waitForNativeReady = useCallback(() => { - if (nativeReadyRef.current && canvasRef.current) { - return Promise.resolve(); - } - - return new Promise((resolve) => { - nativeReadyWaitersRef.current.push(resolve); - }); - }, []); - - const setSlotFrame = useCallback(( - pageIndex: number | null, - height: number, - isVisible: boolean, - ) => { - slotViewRef.current?.setNativeProps({ - style: { - top: pageIndex === null ? OFFSCREEN_TOP : pageIndex * height, - height, - opacity: pageIndex === null || !isVisible ? 0 : 1, - }, - }); - }, []); - - const setNativeCanvasVisible = useCallback((isVisible: boolean) => { - canvasRef.current?.setNativeProps?.({ - style: { - opacity: isVisible ? 1 : 0, - }, - }); - }, []); - - const applyToolState = useCallback((toolState: ContinuousEnginePoolToolState) => { - canvasRef.current?.setTool( - toolState.toolType, - toolState.width, - toolState.color, - toolState.eraserMode, - ); - }, []); - - const applyCurrentTool = useCallback(() => { - applyToolState(getToolStateRef.current()); - }, [applyToolState]); - - const slotRef = useMemo( - () => ({ - 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; - } - }, - isLoaded: () => isLoadedRef.current, - setTool: (toolType, width, color, eraserMode) => { - canvasRef.current?.setTool(toolType, width, color, eraserMode); - }, - clear: () => { - canvasRef.current?.clear(); - }, - undo: () => { - canvasRef.current?.undo(); - }, - redo: () => { - canvasRef.current?.redo(); - }, - performCopy: () => { - canvasRef.current?.performCopy(); - }, - performPaste: () => { - canvasRef.current?.performPaste(); - }, - performDelete: () => { - canvasRef.current?.performDelete(); - }, - runBenchmark: (options) => { - if (!canvasRef.current?.runBenchmark) { - return Promise.reject(new Error("Native benchmark runner is unavailable.")); - } - return canvasRef.current.runBenchmark(options); - }, - startBenchmarkRecording: (options) => { - if (!canvasRef.current?.startBenchmarkRecording) { - return Promise.reject(new Error("Native benchmark recorder is unavailable.")); - } - return canvasRef.current.startBenchmarkRecording(options); - }, - stopBenchmarkRecording: () => { - if (!canvasRef.current?.stopBenchmarkRecording) { - return Promise.reject(new Error("Native benchmark recorder is unavailable.")); - } - return canvasRef.current.stopBenchmarkRecording(); - }, - }), - [], - ); - - const captureLoadedPage = useCallback(async ( - pageId: string | null, - sourceRef: NativeInkCanvasRef | null = canvasRef.current, - ) => { - if ( - !pageId || - !sourceRef || - !isLoadedRef.current || - !shouldCaptureBeforeReassignRef.current(pageId) - ) { - return; - } - - try { - const data = await sourceRef.getBase64Data(); - if (data) { - captureCallbackRef.current(pageId, data); - } - } catch { - // A native serialize can fail during teardown. The normal save path - // still has pages[i].data, and the parent has a blank-overwrite guard. - } - }, []); - - const unregisterCurrentPage = useCallback(() => { - const pageId = registeredPageIdRef.current; - if (!pageId) { - return; - } - registerRef(pageId, null, slotRef); - registeredPageIdRef.current = null; - }, [registerRef, slotRef]); - - const registerAssignment = useCallback((pageId: string) => { - if (registeredPageIdRef.current === pageId) { - return; - } - - unregisterCurrentPage(); - registerRef(pageId, slotRef); - registeredPageIdRef.current = pageId; - }, [registerRef, slotRef, unregisterCurrentPage]); - - const clearAssignment = useCallback(async () => { - const token = loadTokenRef.current + 1; - loadTokenRef.current = token; - const previousPageId = loadedPageIdRef.current; - await captureLoadedPage(previousPageId); - if (loadTokenRef.current !== token) { - return; - } - - unregisterCurrentPage(); - currentAssignmentRef.current = null; - isLoadedRef.current = false; - loadedPageIdRef.current = null; - setNativeCanvasVisible(false); - setSlotFrame(null, canvasHeight, false); - }, [ - canvasHeight, - captureLoadedPage, - setNativeCanvasVisible, - setSlotFrame, - unregisterCurrentPage, - ]); - - const assign = useCallback(async ({ - assignment, - assignmentKey, - canvasHeight: nextCanvasHeight, - backgroundType: nextBackgroundType, - pdfBackgroundBaseUri: nextPdfBackgroundBaseUri, - }: SlotAssignOptions) => { - assignmentKeyRef.current = assignmentKey; - if (!assignment) { - await clearAssignment(); - return; - } - - const nextPageId = assignment.page.id; - const previousPageId = loadedPageIdRef.current; - const previousPageIndex = currentAssignmentRef.current?.pageIndex ?? null; - const isAlreadyLoadedPage = - previousPageId === nextPageId && isLoadedRef.current; - const token = loadTokenRef.current + 1; - loadTokenRef.current = token; - currentAssignmentRef.current = assignment; - - if (!isAlreadyLoadedPage) { - setNativeCanvasVisible(false); - setSlotFrame(previousPageIndex, nextCanvasHeight, false); - await waitForNextFrame(); - if ( - loadTokenRef.current !== token || - currentAssignmentRef.current?.page.id !== nextPageId - ) { - return; - } - } - - setSlotFrame(assignment.pageIndex, nextCanvasHeight, isAlreadyLoadedPage); - - await waitForNativeReady(); - if ( - loadTokenRef.current !== token || - currentAssignmentRef.current?.page.id !== nextPageId - ) { - return; - } - - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - if (isAlreadyLoadedPage) { - canvas.setNativeProps?.({ - backgroundType: nextBackgroundType, - pdfBackgroundUri: getPdfBackgroundUri( - assignment, - nextBackgroundType, - nextPdfBackgroundBaseUri, - ), - style: { - opacity: 1, - }, - }); - registerAssignment(nextPageId); - applyCurrentTool(); - onSlotLoadedRef.current?.( - poolIndex, - assignmentKey, - nextPageId, - assignment.pageIndex, - ); - return; - } - - canvas.setNativeProps?.({ - backgroundType: nextBackgroundType, - pdfBackgroundUri: getPdfBackgroundUri( - assignment, - nextBackgroundType, - nextPdfBackgroundBaseUri, - ), - style: { - opacity: 0, - }, - }); - - if (previousPageId && previousPageId !== nextPageId) { - await captureLoadedPage(previousPageId); - if (loadTokenRef.current !== token) { - return; - } - } - - registerAssignment(nextPageId); - isLoadedRef.current = false; - loadedPageIdRef.current = null; - - try { - await canvas.loadBase64Data(assignment.page.data || BLANK_PAGE_PAYLOAD); - if (loadTokenRef.current !== token) { - return; - } - - loadedPageIdRef.current = nextPageId; - isLoadedRef.current = true; - applyCurrentTool(); - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - if ( - loadTokenRef.current !== token || - currentAssignmentRef.current?.page.id !== nextPageId - ) { - return; - } - - setSlotFrame(assignment.pageIndex, nextCanvasHeight, true); - setNativeCanvasVisible(true); - onSlotLoadedRef.current?.( - poolIndex, - assignmentKey, - nextPageId, - assignment.pageIndex, - ); - } catch { - if (loadTokenRef.current === token) { - loadedPageIdRef.current = null; - isLoadedRef.current = false; - setNativeCanvasVisible(false); - } - } - }, [ - applyCurrentTool, - captureLoadedPage, - clearAssignment, - poolIndex, - registerAssignment, - setNativeCanvasVisible, - setSlotFrame, - waitForNativeReady, - ]); - - const release = useCallback(async () => { - const nativeCanvas = canvasRef.current ?? lastAttachedCanvasRef.current; - const pageId = loadedPageIdRef.current; - loadTokenRef.current += 1; - unregisterCurrentPage(); - if (nativeCanvas) { - if (benchmarkRecordingActiveRef.current) { - await nativeCanvas.stopBenchmarkRecording?.().catch(() => null); - benchmarkRecordingActiveRef.current = false; - } - await captureLoadedPage(pageId, nativeCanvas); - await nativeCanvas.releaseEngine?.().catch(() => {}); - if (lastAttachedCanvasRef.current === nativeCanvas) { - lastAttachedCanvasRef.current = null; - } - } - }, [captureLoadedPage, unregisterCurrentPage]); - - const startBenchmarkRecording = useCallback(async ( - options?: NativeInkBenchmarkRecordingOptions, - ) => { - if (benchmarkRecordingActiveRef.current) { - return true; - } - if (!isLoadedRef.current || !canvasRef.current?.startBenchmarkRecording) { - return false; - } - - const didStart = await canvasRef.current.startBenchmarkRecording(options); - benchmarkRecordingActiveRef.current = didStart; - return didStart; - }, []); - - const stopBenchmarkRecording = useCallback(async () => { - if (!benchmarkRecordingActiveRef.current) { - return null; - } - if (!canvasRef.current?.stopBenchmarkRecording) { - benchmarkRecordingActiveRef.current = false; - return null; - } - - try { - const result = await canvasRef.current.stopBenchmarkRecording(); - benchmarkRecordingActiveRef.current = false; - return result; - } catch (error) { - benchmarkRecordingActiveRef.current = false; - const message = error instanceof Error ? error.message : String(error); - if (message.includes("not running")) { - return null; - } - throw error; - } - }, []); - - useImperativeHandle(ref, () => ({ - assign, - applyToolState, - startBenchmarkRecording, - stopBenchmarkRecording, - release, - }), [ - assign, - applyToolState, - release, - startBenchmarkRecording, - stopBenchmarkRecording, - ]); - - useEffect(() => { - return () => { - void release(); - }; - }, [release]); - - const handleNativeCanvasReady = useCallback(() => { - nativeReadyRef.current = true; - applyCurrentTool(); - if (!forwardedReadyRef.current) { - forwardedReadyRef.current = true; - onCanvasReadyRef.current?.(); - } - - const waiters = nativeReadyWaitersRef.current; - nativeReadyWaitersRef.current = []; - for (const resolve of waiters) { - resolve(); - } - }, [applyCurrentTool]); - - const handleDrawingChange = useCallback(() => { - const pageId = - loadedPageIdRef.current ?? currentAssignmentRef.current?.page.id; - if (pageId) { - onDrawingChange(pageId); - } - }, [onDrawingChange]); - - const handleSelectionChange = useCallback((event: { - nativeEvent: { count: number; bounds?: NativeSelectionBounds | null }; - }) => { - const pageId = - loadedPageIdRef.current ?? currentAssignmentRef.current?.page.id; - if (pageId) { - onSelectionChangeRef.current?.( - pageId, - event.nativeEvent.count, - event.nativeEvent.bounds ?? null, - ); - } - }, []); - - return ( - - - - ); - }, -), (prev, next) => ( - prev.poolIndex === next.poolIndex && - prev.canvasHeight === next.canvasHeight && - prev.backgroundType === next.backgroundType && - prev.renderBackend === next.renderBackend && - prev.pdfBackgroundBaseUri === next.pdfBackgroundBaseUri && - prev.drawingPolicy === next.drawingPolicy && - prev.getToolState === next.getToolState && - prev.onCanvasReady === next.onCanvasReady && - prev.onSlotLoaded === next.onSlotLoaded && - prev.onDrawingChange === next.onDrawingChange && - prev.onSelectionChange === next.onSelectionChange && - prev.onDrawingBegin === next.onDrawingBegin && - prev.onPencilDoubleTap === next.onPencilDoubleTap && - prev.registerRef === next.registerRef && - prev.shouldCaptureBeforeReassign === next.shouldCaptureBeforeReassign && - prev.onCaptureBeforeReassign === next.onCaptureBeforeReassign -)); +export type { + ContinuousEnginePoolAssignment, + ContinuousEnginePoolProps, + ContinuousEnginePoolRef, + ContinuousEnginePoolSlotRef, + ContinuousEnginePoolToolState, +} from "./continuous-engine-pool/types"; export const ContinuousEnginePool = memo(forwardRef< ContinuousEnginePoolRef, @@ -964,17 +304,3 @@ export const ContinuousEnginePool = memo(forwardRef< prev.shouldCaptureBeforeReassign === next.shouldCaptureBeforeReassign && prev.onSlotCaptureBeforeUnmount === next.onSlotCaptureBeforeUnmount )); - -const styles = StyleSheet.create({ - slot: { - position: "absolute", - left: 0, - right: 0, - top: OFFSCREEN_TOP, - height: 0, - opacity: 0, - }, - nativeCanvas: { - ...StyleSheet.absoluteFillObject, - }, -}); diff --git a/src/NativeInkCanvas.tsx b/src/NativeInkCanvas.tsx index 504bcf9..62b8eef 100644 --- a/src/NativeInkCanvas.tsx +++ b/src/NativeInkCanvas.tsx @@ -7,13 +7,9 @@ import React, { useState, } from 'react'; import { - requireNativeComponent, UIManager, findNodeHandle, Platform, - ViewStyle, - NativeModules, - NativeSyntheticEvent, } from 'react-native'; import { DEFAULT_NATIVE_INK_RENDER_BACKEND, @@ -22,153 +18,30 @@ import type { NativeInkBenchmarkOptions, NativeInkBenchmarkRecordingOptions, NativeInkBenchmarkResult, - NativeInkRenderBackend, } from './benchmark'; -import type { NativeSelectionBounds } from './types'; import { normalizePagePayloadForNativeLoad } from "./payload"; +import { + MobileInkBridge, + MobileInkCanvasViewManager, + MobileInkCanvasViewNative, + MobileInkModule, + supportsRenderBackendProp, +} from "./native-ink-canvas/nativeModules"; +import type { + NativeInkCanvasProps, + NativeInkCanvasRef, +} from "./native-ink-canvas/types"; -// NativeModules for callback-based methods -// iOS uses MobileInkCanvasViewManager (ViewManager methods) -// Android uses MobileInkModule (separate module with promise-based API) -const MobileInkCanvasViewManager = Platform.OS === 'ios' - ? NativeModules.MobileInkCanvasViewManager - : null; -const MobileInkModule = Platform.OS === 'android' - ? NativeModules.MobileInkModule - : null; -// MobileInkBridge for iOS batch export (static methods not tied to view) -const MobileInkBridge = Platform.OS === 'ios' - ? NativeModules.MobileInkBridge - : null; - -if (__DEV__) { - if (Platform.OS === 'ios' && !MobileInkCanvasViewManager) { - console.warn('[NativeInkCanvas] MobileInkCanvasViewManager not found in NativeModules. Drawing serialization may not work.'); - } - if (Platform.OS === 'android' && !MobileInkModule) { - console.warn('[NativeInkCanvas] MobileInkModule not found in NativeModules. Drawing serialization may not work.'); - } -} - -const LINKING_ERROR = - `The package 'MobileInkCanvasView' doesn't seem to be linked. Make sure: \n\n` + - Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + - '- You rebuilt the app after installing the package\n' + - '- You are not using Expo Go\n'; - -const ComponentName = 'MobileInkCanvasView'; - -const mobileInkCanvasViewConfig = UIManager.getViewManagerConfig(ComponentName) as - | { NativeProps?: Record } - | null; -const supportsRenderBackendProp = - !!mobileInkCanvasViewConfig?.NativeProps && - Object.prototype.hasOwnProperty.call( - mobileInkCanvasViewConfig.NativeProps, - 'renderBackend', - ); - -const MobileInkCanvasViewNative = - mobileInkCanvasViewConfig != null - ? requireNativeComponent(ComponentName) - : () => { - throw new Error(LINKING_ERROR); - }; - -export interface NativeInkCanvasProps { - style?: ViewStyle; - onDrawingChange?: () => void; - onDrawingBegin?: (event: NativeSyntheticEvent<{ x: number; y: number }>) => void; - onSelectionChange?: (event: { nativeEvent: { count: number; bounds?: NativeSelectionBounds | null } }) => void; - onCanvasReady?: () => void; - backgroundType?: string; - pdfBackgroundUri?: string; - renderSuspended?: boolean; - /** iOS only: Chooses the native render path for A/B performance tests. */ - renderBackend?: NativeInkRenderBackend; - /** 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+) */ - onPencilDoubleTap?: (event: NativeSyntheticEvent<{ sequence: number; timestamp: number }>) => void; -} - -export interface NativeInkCanvasRef { - setNativeProps?: (nativeProps: Record) => void; - clear: () => void; - undo: () => void; - redo: () => void; - setTool: (toolType: string, width: number, color: string, eraserMode?: string) => void; - getBase64Data: () => Promise; - loadBase64Data: (base64String: string) => Promise; - /** - * Eagerly release the heavy native state (~13 MB pixel buffer + the - * C++ drawing engine + queued JS callbacks) without waiting for ARC. - * The continuous engine pool calls this only on final pool unmount, - * never for normal page switching. Optional so tests don't have to - * mock it; iOS-only. - */ - releaseEngine?: () => Promise; - /** - * Native-side single-page persistence: tells the engine to serialize its - * current state (one page payload) and write directly to the file at - * `path`. Body bytes never cross the JS<->native bridge. - * - * Useful for paged-mode (Android primary) where one engine = one page. - * Continuous mode should use persistFullNotebookToFile instead so non- - * visible pages are preserved. - */ - persistEngineToFile: (path: string) => Promise; - loadEngineFromFile: (path: string) => Promise; - /** - * Native-side full-notebook autosave (iOS continuous mode). - * - * Reads the existing body file, replaces ONLY the visible window's - * per-page data with the engine's fresh state, writes back atomically. - * Body bytes (which can be many MB) never cross the JS<->native bridge: - * JS only sends the small visible-page-IDs array + lightweight - * pagesMetadata (no data fields). - * - * Returns true on success. Returns false (without throwing) when the - * native fast-path isn't available (older build) so callers can fall - * back to the existing slow path. - */ - persistFullNotebookToFile: (params: { - visiblePageIds: string[]; - pagesMetadata: Array>; - originalCanvasWidth?: number; - pageHeight: number; - bodyPath: string; - }) => Promise; - /** - * Inverse of persistFullNotebookToFile. Reads the body file in native, - * loads visible-window pages into the engine, returns just the slim - * metadata array (no per-page data) plus the originalCanvasWidth. - * - * Returns null (without throwing) when the file is missing, malformed, - * or the native fast-path isn't available. - */ - loadNotebookForVisibleWindow: (params: { - bodyPath: string; - visiblePageIds: string[]; - pageHeight: number; - }) => Promise<{ - success: boolean; - pagesMetadata?: Array>; - originalCanvasWidth?: number | null; - reason?: string; - } | null>; - stageBase64Data?: (base64String: string) => Promise; - presentDeferredLoad?: () => Promise; - getBase64PngData: (scale?: number) => Promise; - getBase64JpegData: (scale?: number, compression?: number) => Promise; - performCopy: () => void; - performPaste: () => void; - performDelete: () => void; - simulatePencilDoubleTap?: () => Promise; - runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; - startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; - stopBenchmarkRecording?: () => Promise; -} +export type { + NativeInkCanvasProps, + NativeInkCanvasRef, +} from "./native-ink-canvas/types"; +export { + batchExportPages, + composeContinuousWindow, + decomposeContinuousWindow, + readBodyFileParsed, +} from "./native-ink-canvas/notebookBridge"; export const NativeInkCanvas = forwardRef< NativeInkCanvasRef, @@ -714,150 +587,3 @@ export const NativeInkCanvas = forwardRef< }); NativeInkCanvas.displayName = 'NativeInkCanvas'; - -/** - * Batch export multiple pages to PNG images natively. - * This is much faster than exporting pages one by one because it: - * 1. Creates a single Skia engine and surface (reused for all pages) - * 2. Doesn't switch visible pages (no UI updates) - * 3. Processes all pages in a single native call - * - * @param pagesData Array of page data objects (JSON format with base64 drawing data) - * @param backgroundTypes Array of background type strings per page - * @param width Canvas width in pixels - * @param height Canvas height in pixels - * @param scale Export scale factor (e.g., 2.0 for retina) - * @param pdfBackgroundUri Optional PDF file URI for PDF backgrounds - * @returns Array of base64 PNG data URIs - */ -export async function batchExportPages( - pagesData: string[], - backgroundTypes: string[], - width: number, - height: number, - scale: number = 2.0, - pdfBackgroundUri?: string, - pageIndices?: number[], -): Promise { - if (pagesData.length === 0) { - return []; - } - - __DEV__ && console.log(`[BatchExport] Starting native batch export of ${pagesData.length} pages at ${width}x${height} scale=${scale}`); - const startTime = Date.now(); - - try { - const sanitizedPagesData = pagesData.map((pageData, index) => { - const normalized = normalizePagePayloadForNativeLoad(pageData); - if (!normalized.isValid) { - console.warn(`[BatchExport] Replacing invalid page payload at index ${index} with a blank page (${normalized.reasonCode})`); - return '{"pages":{}}'; - } - return normalized.normalizedPayload || '{"pages":{}}'; - }); - let results: string[]; - - if (Platform.OS === 'ios') { - if (!MobileInkBridge) { - throw new Error('MobileInkBridge not found. Please rebuild the app.'); - } - // iOS: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri) - results = await MobileInkBridge.batchExportPages( - sanitizedPagesData, - backgroundTypes, - width, - height, - scale, - pdfBackgroundUri || '', - pageIndices || [] - ); - } else { - if (!MobileInkModule) { - throw new Error('MobileInkModule not found. Please rebuild the app.'); - } - // Android: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri) - results = await MobileInkModule.batchExportPages( - sanitizedPagesData, - backgroundTypes, - width, - height, - scale, - pdfBackgroundUri || '' - ); - } - - const elapsed = Date.now() - startTime; - const successCount = results.filter(r => r && r.length > 0).length; - __DEV__ && console.log(`[BatchExport] Completed ${successCount}/${pagesData.length} pages in ${elapsed}ms`); - - return results; - } catch (error) { - console.error('[BatchExport] Native batch export failed:', error); - throw error; - } -} - -/** - * Native body-file read + parse. - * - * Reads the notebook body file in C++ via NSJSONSerialization and returns - * the parsed structure to JS. Skips Hermes JSON.parse on a multi-MB string, - * which is the dominant cost of opening a heavy notebook. - * - * Resolves with `null` if the file doesn't exist (caller treats as new file) - * or if the native fast path isn't available (older build). Rejects on real - * read/parse errors so the caller can fall back to the slow path. - * - * iOS-only: MobileInkBridge ships the parser. Android falls through to - * the existing JS-side read+parse path. - */ -export async function readBodyFileParsed( - bodyPath: string, -): Promise | null> { - if (Platform.OS !== 'ios' || !MobileInkBridge?.readBodyFileParsed) { - return null; - } - try { - const result = await MobileInkBridge.readBodyFileParsed(bodyPath); - if (result === null || result === undefined) return null; - if (typeof result !== 'object') return null; - return result as Record; - } catch (error) { - // Native parse failed -- fall through to JS-side read. - if (__DEV__) { - console.warn('[NativeInkCanvas] readBodyFileParsed failed:', error); - } - return null; - } -} - -export async function composeContinuousWindow( - pagePayloads: string[], - pageHeight: number -): Promise { - if (Platform.OS !== 'ios') { - throw new Error('Continuous window composition is only available on iOS.'); - } - - if (!MobileInkBridge?.composeContinuousWindow) { - throw new Error('MobileInkBridge.composeContinuousWindow not found. Please rebuild the app.'); - } - - return MobileInkBridge.composeContinuousWindow(pagePayloads, pageHeight); -} - -export async function decomposeContinuousWindow( - windowPayload: string, - pageCount: number, - pageHeight: number -): Promise { - if (Platform.OS !== 'ios') { - throw new Error('Continuous window decomposition is only available on iOS.'); - } - - if (!MobileInkBridge?.decomposeContinuousWindow) { - throw new Error('MobileInkBridge.decomposeContinuousWindow not found. Please rebuild the app.'); - } - - return MobileInkBridge.decomposeContinuousWindow(windowPayload, pageCount, pageHeight); -} diff --git a/src/ZoomableInkViewport.tsx b/src/ZoomableInkViewport.tsx index 45cd864..b9108d0 100644 --- a/src/ZoomableInkViewport.tsx +++ b/src/ZoomableInkViewport.tsx @@ -1,5 +1,5 @@ -import React, { forwardRef, useImperativeHandle, useCallback, useMemo, RefObject } from 'react'; -import { StyleSheet, View, ViewStyle, LayoutChangeEvent } from 'react-native'; +import React, { forwardRef, useImperativeHandle, useCallback, useMemo } from 'react'; +import { StyleSheet, View, LayoutChangeEvent } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, @@ -10,86 +10,17 @@ import Animated, { cancelAnimation, runOnJS, } from 'react-native-reanimated'; - -export interface ZoomableInkViewportRef { - resetZoom: () => void; - resetZoomAnimated: () => void; - isZoomed: () => boolean; - getScale: () => number; - getTransform: () => { - scale: number; - translateX: number; - translateY: number; - containerWidth: number; - containerHeight: number; - }; - setTransform: (nextTransform: { - scale?: number; - translateX?: number; - translateY?: number; - animated?: boolean; - }) => void; -} - -export interface TouchExclusionRect { - left: number; - top: number; - right: number; - bottom: number; -} - -export interface ZoomableInkViewportProps { - children: React.ReactNode; - style?: ViewStyle; - minScale?: number; - maxScale?: number; - enabled?: boolean; - onZoomChange?: (scale: number) => void; - /** Called when gesture state changes - use to block canvas touches */ - onGestureStateChange?: (isGesturing: boolean) => void; - /** Called when viewport movement starts or stops, including decay/clamp animation. */ - onMotionStateChange?: (isMoving: boolean) => void; - /** Content width - used to calculate pan bounds in landscape */ - contentWidth?: number; - /** Content height - used to calculate pan bounds in landscape */ - contentHeight?: number; - /** Padding around content (e.g., from canvasShell) - affects pan bounds */ - contentPadding?: number; - /** Whether device is in landscape orientation - affects pan bounds alignment */ - isLandscape?: boolean; - /** When true (finger mode), pan requires 2 fingers. When false (pencil mode), 1-finger pan is enabled. */ - fingerDrawingEnabled?: boolean; - /** Width of edge zones (px) where 1-finger touches are rejected to allow page swipe */ - edgeExclusionWidth?: number; - /** Ref attached to the clip container for viewport capture (snipping) */ - viewportRef?: RefObject; - /** Finger-only interactive regions that should block navigation gestures. */ - blockedTouchRects?: TouchExclusionRect[]; - /** When true, pan gestures continue with inertial decay after release. */ - enableMomentumScroll?: boolean; - /** When false, pinch stays available but one/two-finger content pan is disabled. */ - panEnabled?: boolean; - /** Locks small horizontal drift while continuous content is near fit scale. */ - lockHorizontalPanNearFit?: boolean; - /** Reports viewport transform changes for virtualized layouts. */ - onTransformChange?: (transform: { - scale: number; - translateX: number; - translateY: number; - containerWidth: number; - containerHeight: number; - }) => void; - /** Minimum time between JS transform notifications; UI-thread scrolling remains unthrottled. */ - transformNotificationMinIntervalMs?: number; - /** Short finger taps reported in untransformed content coordinates. */ - onContentTap?: (event: { - nativeEvent: { - locationX: number; - locationY: number; - isZoomableContentTap: true; - }; - }) => void; -} +import type { + TouchExclusionRect, + ZoomableInkViewportProps, + ZoomableInkViewportRef, +} from './zoomable-ink-viewport/types'; + +export type { + TouchExclusionRect, + ZoomableInkViewportProps, + ZoomableInkViewportRef, +} from './zoomable-ink-viewport/types'; const HORIZONTAL_LOCK_MAX_SCALE = 1.12; diff --git a/src/continuous-engine-pool/PooledCanvasSlot.tsx b/src/continuous-engine-pool/PooledCanvasSlot.tsx new file mode 100644 index 0000000..0300c3d --- /dev/null +++ b/src/continuous-engine-pool/PooledCanvasSlot.tsx @@ -0,0 +1,554 @@ +import React, { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import { StyleSheet, View } from "react-native"; +import { NativeInkCanvas } from "../NativeInkCanvas"; +import type { NativeInkBenchmarkRecordingOptions } from "../benchmark"; +import type { NativeSelectionBounds } from "../types"; +import { + BLANK_PAGE_PAYLOAD, + getPdfBackgroundUri, + OFFSCREEN_TOP, + waitForNextFrame, +} from "./helpers"; +import type { + ContinuousEnginePoolAssignment, + ContinuousEnginePoolSlotRef, + ContinuousEnginePoolToolState, + NativeCanvasRef, + PooledCanvasSlotAssignOptions, + PooledCanvasSlotHandle, + PooledCanvasSlotProps, +} from "./types"; + +export const PooledCanvasSlot = memo(forwardRef( + function PooledCanvasSlot({ + poolIndex, + canvasHeight, + backgroundType, + renderBackend, + pdfBackgroundBaseUri, + drawingPolicy, + getToolState, + onCanvasReady, + onSlotLoaded, + onDrawingChange, + onSelectionChange, + onDrawingBegin, + onPencilDoubleTap, + registerRef, + shouldCaptureBeforeReassign, + onCaptureBeforeReassign, + }, ref) { + const slotViewRef = useRef(null); + const canvasRef = useRef(null); + const lastAttachedCanvasRef = useRef(null); + const nativeReadyRef = useRef(false); + const nativeReadyWaitersRef = useRef void>>([]); + const isLoadedRef = useRef(false); + const loadedPageIdRef = useRef(null); + const registeredPageIdRef = useRef(null); + const currentAssignmentRef = useRef(null); + const loadTokenRef = useRef(0); + const forwardedReadyRef = useRef(false); + const benchmarkRecordingActiveRef = useRef(false); + const shouldCaptureBeforeReassignRef = useRef(shouldCaptureBeforeReassign); + const captureCallbackRef = useRef(onCaptureBeforeReassign); + const onCanvasReadyRef = useRef(onCanvasReady); + const onSlotLoadedRef = useRef(onSlotLoaded); + const onSelectionChangeRef = useRef(onSelectionChange); + const getToolStateRef = useRef(getToolState); + + shouldCaptureBeforeReassignRef.current = shouldCaptureBeforeReassign; + captureCallbackRef.current = onCaptureBeforeReassign; + onCanvasReadyRef.current = onCanvasReady; + onSlotLoadedRef.current = onSlotLoaded; + onSelectionChangeRef.current = onSelectionChange; + getToolStateRef.current = getToolState; + + const setNativeCanvasRef = useCallback((nextRef: NativeCanvasRef | null) => { + canvasRef.current = nextRef; + if (nextRef) { + lastAttachedCanvasRef.current = nextRef; + } + }, []); + + const waitForNativeReady = useCallback(() => { + if (nativeReadyRef.current && canvasRef.current) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + nativeReadyWaitersRef.current.push(resolve); + }); + }, []); + + const setSlotFrame = useCallback(( + pageIndex: number | null, + height: number, + isVisible: boolean, + ) => { + slotViewRef.current?.setNativeProps({ + style: { + top: pageIndex === null ? OFFSCREEN_TOP : pageIndex * height, + height, + opacity: pageIndex === null || !isVisible ? 0 : 1, + }, + }); + }, []); + + const setNativeCanvasVisible = useCallback((isVisible: boolean) => { + canvasRef.current?.setNativeProps?.({ + style: { + opacity: isVisible ? 1 : 0, + }, + }); + }, []); + + const applyToolState = useCallback((toolState: ContinuousEnginePoolToolState) => { + canvasRef.current?.setTool( + toolState.toolType, + toolState.width, + toolState.color, + toolState.eraserMode, + ); + }, []); + + const applyCurrentTool = useCallback(() => { + applyToolState(getToolStateRef.current()); + }, [applyToolState]); + + const slotRef = useMemo( + () => ({ + 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; + } + }, + isLoaded: () => isLoadedRef.current, + setTool: (toolType, width, color, eraserMode) => { + canvasRef.current?.setTool(toolType, width, color, eraserMode); + }, + clear: () => { + canvasRef.current?.clear(); + }, + undo: () => { + canvasRef.current?.undo(); + }, + redo: () => { + canvasRef.current?.redo(); + }, + performCopy: () => { + canvasRef.current?.performCopy(); + }, + performPaste: () => { + canvasRef.current?.performPaste(); + }, + performDelete: () => { + canvasRef.current?.performDelete(); + }, + runBenchmark: (options) => { + if (!canvasRef.current?.runBenchmark) { + return Promise.reject(new Error("Native benchmark runner is unavailable.")); + } + return canvasRef.current.runBenchmark(options); + }, + startBenchmarkRecording: (options) => { + if (!canvasRef.current?.startBenchmarkRecording) { + return Promise.reject(new Error("Native benchmark recorder is unavailable.")); + } + return canvasRef.current.startBenchmarkRecording(options); + }, + stopBenchmarkRecording: () => { + if (!canvasRef.current?.stopBenchmarkRecording) { + return Promise.reject(new Error("Native benchmark recorder is unavailable.")); + } + return canvasRef.current.stopBenchmarkRecording(); + }, + }), + [], + ); + + const captureLoadedPage = useCallback(async ( + pageId: string | null, + sourceRef: NativeCanvasRef | null = canvasRef.current, + ) => { + if ( + !pageId || + !sourceRef || + !isLoadedRef.current || + !shouldCaptureBeforeReassignRef.current(pageId) + ) { + return; + } + + try { + const data = await sourceRef.getBase64Data(); + if (data) { + captureCallbackRef.current(pageId, data); + } + } catch { + // A native serialize can fail during teardown. The normal save path + // still has pages[i].data, and the parent has a blank-overwrite guard. + } + }, []); + + const unregisterCurrentPage = useCallback(() => { + const pageId = registeredPageIdRef.current; + if (!pageId) { + return; + } + registerRef(pageId, null, slotRef); + registeredPageIdRef.current = null; + }, [registerRef, slotRef]); + + const registerAssignment = useCallback((pageId: string) => { + if (registeredPageIdRef.current === pageId) { + return; + } + + unregisterCurrentPage(); + registerRef(pageId, slotRef); + registeredPageIdRef.current = pageId; + }, [registerRef, slotRef, unregisterCurrentPage]); + + const clearAssignment = useCallback(async () => { + const token = loadTokenRef.current + 1; + loadTokenRef.current = token; + const previousPageId = loadedPageIdRef.current; + await captureLoadedPage(previousPageId); + if (loadTokenRef.current !== token) { + return; + } + + unregisterCurrentPage(); + currentAssignmentRef.current = null; + isLoadedRef.current = false; + loadedPageIdRef.current = null; + setNativeCanvasVisible(false); + setSlotFrame(null, canvasHeight, false); + }, [ + canvasHeight, + captureLoadedPage, + setNativeCanvasVisible, + setSlotFrame, + unregisterCurrentPage, + ]); + + const assign = useCallback(async ({ + assignment, + assignmentKey, + canvasHeight: nextCanvasHeight, + backgroundType: nextBackgroundType, + pdfBackgroundBaseUri: nextPdfBackgroundBaseUri, + }: PooledCanvasSlotAssignOptions) => { + if (!assignment) { + await clearAssignment(); + return; + } + + const nextPageId = assignment.page.id; + const previousPageId = loadedPageIdRef.current; + const previousPageIndex = currentAssignmentRef.current?.pageIndex ?? null; + const isAlreadyLoadedPage = + previousPageId === nextPageId && isLoadedRef.current; + const token = loadTokenRef.current + 1; + loadTokenRef.current = token; + currentAssignmentRef.current = assignment; + + if (!isAlreadyLoadedPage) { + setNativeCanvasVisible(false); + setSlotFrame(previousPageIndex, nextCanvasHeight, false); + await waitForNextFrame(); + if ( + loadTokenRef.current !== token || + currentAssignmentRef.current?.page.id !== nextPageId + ) { + return; + } + } + + setSlotFrame(assignment.pageIndex, nextCanvasHeight, isAlreadyLoadedPage); + + await waitForNativeReady(); + if ( + loadTokenRef.current !== token || + currentAssignmentRef.current?.page.id !== nextPageId + ) { + return; + } + + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + if (isAlreadyLoadedPage) { + canvas.setNativeProps?.({ + backgroundType: nextBackgroundType, + pdfBackgroundUri: getPdfBackgroundUri( + assignment, + nextBackgroundType, + nextPdfBackgroundBaseUri, + ), + style: { + opacity: 1, + }, + }); + registerAssignment(nextPageId); + applyCurrentTool(); + onSlotLoadedRef.current?.( + poolIndex, + assignmentKey, + nextPageId, + assignment.pageIndex, + ); + return; + } + + canvas.setNativeProps?.({ + backgroundType: nextBackgroundType, + pdfBackgroundUri: getPdfBackgroundUri( + assignment, + nextBackgroundType, + nextPdfBackgroundBaseUri, + ), + style: { + opacity: 0, + }, + }); + + if (previousPageId && previousPageId !== nextPageId) { + await captureLoadedPage(previousPageId); + if (loadTokenRef.current !== token) { + return; + } + } + + registerAssignment(nextPageId); + isLoadedRef.current = false; + loadedPageIdRef.current = null; + + try { + await canvas.loadBase64Data(assignment.page.data || BLANK_PAGE_PAYLOAD); + if (loadTokenRef.current !== token) { + return; + } + + loadedPageIdRef.current = nextPageId; + isLoadedRef.current = true; + applyCurrentTool(); + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + if ( + loadTokenRef.current !== token || + currentAssignmentRef.current?.page.id !== nextPageId + ) { + return; + } + + setSlotFrame(assignment.pageIndex, nextCanvasHeight, true); + setNativeCanvasVisible(true); + onSlotLoadedRef.current?.( + poolIndex, + assignmentKey, + nextPageId, + assignment.pageIndex, + ); + } catch { + if (loadTokenRef.current === token) { + loadedPageIdRef.current = null; + isLoadedRef.current = false; + setNativeCanvasVisible(false); + } + } + }, [ + applyCurrentTool, + captureLoadedPage, + clearAssignment, + poolIndex, + registerAssignment, + setNativeCanvasVisible, + setSlotFrame, + waitForNativeReady, + ]); + + const release = useCallback(async () => { + const nativeCanvas = canvasRef.current ?? lastAttachedCanvasRef.current; + const pageId = loadedPageIdRef.current; + loadTokenRef.current += 1; + unregisterCurrentPage(); + if (nativeCanvas) { + if (benchmarkRecordingActiveRef.current) { + await nativeCanvas.stopBenchmarkRecording?.().catch(() => null); + benchmarkRecordingActiveRef.current = false; + } + await captureLoadedPage(pageId, nativeCanvas); + await nativeCanvas.releaseEngine?.().catch(() => {}); + if (lastAttachedCanvasRef.current === nativeCanvas) { + lastAttachedCanvasRef.current = null; + } + } + }, [captureLoadedPage, unregisterCurrentPage]); + + const startBenchmarkRecording = useCallback(async ( + options?: NativeInkBenchmarkRecordingOptions, + ) => { + if (benchmarkRecordingActiveRef.current) { + return true; + } + if (!isLoadedRef.current || !canvasRef.current?.startBenchmarkRecording) { + return false; + } + + const didStart = await canvasRef.current.startBenchmarkRecording(options); + benchmarkRecordingActiveRef.current = didStart; + return didStart; + }, []); + + const stopBenchmarkRecording = useCallback(async () => { + if (!benchmarkRecordingActiveRef.current) { + return null; + } + if (!canvasRef.current?.stopBenchmarkRecording) { + benchmarkRecordingActiveRef.current = false; + return null; + } + + try { + const result = await canvasRef.current.stopBenchmarkRecording(); + benchmarkRecordingActiveRef.current = false; + return result; + } catch (error) { + benchmarkRecordingActiveRef.current = false; + const message = error instanceof Error ? error.message : String(error); + if (message.includes("not running")) { + return null; + } + throw error; + } + }, []); + + useImperativeHandle(ref, () => ({ + assign, + applyToolState, + startBenchmarkRecording, + stopBenchmarkRecording, + release, + }), [ + assign, + applyToolState, + release, + startBenchmarkRecording, + stopBenchmarkRecording, + ]); + + useEffect(() => { + return () => { + void release(); + }; + }, [release]); + + const handleNativeCanvasReady = useCallback(() => { + nativeReadyRef.current = true; + applyCurrentTool(); + if (!forwardedReadyRef.current) { + forwardedReadyRef.current = true; + onCanvasReadyRef.current?.(); + } + + const waiters = nativeReadyWaitersRef.current; + nativeReadyWaitersRef.current = []; + for (const resolve of waiters) { + resolve(); + } + }, [applyCurrentTool]); + + const handleDrawingChange = useCallback(() => { + const pageId = + loadedPageIdRef.current ?? currentAssignmentRef.current?.page.id; + if (pageId) { + onDrawingChange(pageId); + } + }, [onDrawingChange]); + + const handleSelectionChange = useCallback((event: { + nativeEvent: { count: number; bounds?: NativeSelectionBounds | null }; + }) => { + const pageId = + loadedPageIdRef.current ?? currentAssignmentRef.current?.page.id; + if (pageId) { + onSelectionChangeRef.current?.( + pageId, + event.nativeEvent.count, + event.nativeEvent.bounds ?? null, + ); + } + }, []); + + return ( + + + + ); + }, +), (prev, next) => ( + prev.poolIndex === next.poolIndex && + prev.canvasHeight === next.canvasHeight && + prev.backgroundType === next.backgroundType && + prev.renderBackend === next.renderBackend && + prev.pdfBackgroundBaseUri === next.pdfBackgroundBaseUri && + prev.drawingPolicy === next.drawingPolicy && + prev.getToolState === next.getToolState && + prev.onCanvasReady === next.onCanvasReady && + prev.onSlotLoaded === next.onSlotLoaded && + prev.onDrawingChange === next.onDrawingChange && + prev.onSelectionChange === next.onSelectionChange && + prev.onDrawingBegin === next.onDrawingBegin && + prev.onPencilDoubleTap === next.onPencilDoubleTap && + prev.registerRef === next.registerRef && + prev.shouldCaptureBeforeReassign === next.shouldCaptureBeforeReassign && + prev.onCaptureBeforeReassign === next.onCaptureBeforeReassign +)); + +const styles = StyleSheet.create({ + slot: { + position: "absolute", + left: 0, + right: 0, + top: OFFSCREEN_TOP, + height: 0, + opacity: 0, + }, + nativeCanvas: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/src/continuous-engine-pool/helpers.ts b/src/continuous-engine-pool/helpers.ts new file mode 100644 index 0000000..576682f --- /dev/null +++ b/src/continuous-engine-pool/helpers.ts @@ -0,0 +1,35 @@ +import type { ContinuousEnginePoolAssignment } from "./types"; + +export const BLANK_PAGE_PAYLOAD = '{"pages":{}}'; +export const OFFSCREEN_TOP = -100000; + +export const getAssignmentKey = ( + assignments: ContinuousEnginePoolAssignment[], +) => { + return assignments + .map(({ page, pageIndex }) => [ + pageIndex, + page.id, + page.pdfPageNumber ?? "", + page.rotation ?? 0, + ].join(":")) + .join("|"); +}; + +export const getPdfBackgroundUri = ( + assignment: ContinuousEnginePoolAssignment, + backgroundType: string, + pdfBackgroundBaseUri?: string, +) => { + if (backgroundType !== "pdf" || !pdfBackgroundBaseUri) { + return undefined; + } + + return `${pdfBackgroundBaseUri}#page=${ + assignment.page.pdfPageNumber || assignment.pageIndex + 1 + }`; +}; + +export const waitForNextFrame = () => new Promise((resolve) => { + requestAnimationFrame(() => resolve()); +}); diff --git a/src/continuous-engine-pool/types.ts b/src/continuous-engine-pool/types.ts new file mode 100644 index 0000000..fad99f6 --- /dev/null +++ b/src/continuous-engine-pool/types.ts @@ -0,0 +1,132 @@ +import type { + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkRenderBackend, +} from "../benchmark"; +import type { + NativeInkCanvasProps, + NativeInkCanvasRef, +} from "../NativeInkCanvas"; +import type { NativeSelectionBounds, NotebookPage, ToolType } from "../types"; + +export type ContinuousEnginePoolAssignment = { + page: NotebookPage; + pageIndex: number; +}; + +export type ContinuousEnginePoolToolState = { + toolType: ToolType; + width: number; + color: string; + eraserMode: string; +}; + +export type ContinuousEnginePoolSlotRef = { + getBase64Data: () => Promise; + isLoaded: () => boolean; + setTool: ( + toolType: string, + width: number, + color: string, + eraserMode: string, + ) => void; + clear: () => void; + undo: () => void; + redo: () => void; + performCopy: () => void; + performPaste: () => void; + performDelete: () => void; + runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; + startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording?: () => Promise; +}; + +export type ContinuousEnginePoolRef = { + assignPages: (assignments: ContinuousEnginePoolAssignment[]) => Promise; + applyToolState: (toolState: ContinuousEnginePoolToolState) => void; + startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording: () => Promise; + release: () => Promise; +}; + +export type ContinuousEnginePoolProps = { + canvasHeight: number; + backgroundType: string; + renderBackend?: NativeInkRenderBackend; + pdfBackgroundBaseUri: string | undefined; + fingerDrawingEnabled: boolean; + getToolState: () => ContinuousEnginePoolToolState; + onCanvasReady: () => void; + onAssignmentReady?: (assignmentKey: string) => void; + onPageAssignmentReady?: ( + pageId: string, + pageIndex: number, + assignmentKey: string, + ) => void; + onPerPageDrawingChange: (pageId: string) => void; + onPerPageSelectionChange?: ( + pageId: string, + count: number, + bounds: NativeSelectionBounds | null, + ) => void; + onDrawingBegin?: () => void; + onPencilDoubleTap?: NativeInkCanvasProps["onPencilDoubleTap"]; + registerPerPageSlot: ( + pageId: string, + ref: ContinuousEnginePoolSlotRef | null, + sourceRef?: ContinuousEnginePoolSlotRef, + ) => void; + shouldCaptureBeforeReassign: (pageId: string) => boolean; + onSlotCaptureBeforeUnmount: (pageId: string, data: string) => void; +}; + +export type PooledCanvasSlotAssignOptions = { + assignment: ContinuousEnginePoolAssignment | null; + assignmentKey: string; + canvasHeight: number; + backgroundType: string; + pdfBackgroundBaseUri?: string; +}; + +export type PooledCanvasSlotHandle = { + assign: (options: PooledCanvasSlotAssignOptions) => Promise; + applyToolState: (toolState: ContinuousEnginePoolToolState) => void; + startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording: () => Promise; + release: () => Promise; +}; + +export type PooledCanvasSlotProps = { + poolIndex: number; + canvasHeight: number; + backgroundType: string; + renderBackend?: NativeInkRenderBackend; + pdfBackgroundBaseUri?: string; + drawingPolicy: "anyinput" | "pencilonly"; + getToolState: () => ContinuousEnginePoolToolState; + onCanvasReady?: () => void; + onSlotLoaded: ( + poolIndex: number, + assignmentKey: string, + pageId: string, + pageIndex: number, + ) => void; + onDrawingChange: (pageId: string) => void; + onSelectionChange?: ( + pageId: string, + count: number, + bounds: NativeSelectionBounds | null, + ) => void; + onDrawingBegin?: () => void; + onPencilDoubleTap?: NativeInkCanvasProps["onPencilDoubleTap"]; + registerRef: ( + pageId: string, + ref: ContinuousEnginePoolSlotRef | null, + sourceRef?: ContinuousEnginePoolSlotRef, + ) => void; + shouldCaptureBeforeReassign: (pageId: string) => boolean; + onCaptureBeforeReassign: (pageId: string, data: string) => void; +}; + +export type NativeCanvasRef = NativeInkCanvasRef; diff --git a/src/native-ink-canvas/nativeModules.ts b/src/native-ink-canvas/nativeModules.ts new file mode 100644 index 0000000..c95d279 --- /dev/null +++ b/src/native-ink-canvas/nativeModules.ts @@ -0,0 +1,51 @@ +import { + NativeModules, + Platform, + requireNativeComponent, + UIManager, +} from "react-native"; + +const COMPONENT_NAME = "MobileInkCanvasView"; + +export const MobileInkCanvasViewManager = Platform.OS === "ios" + ? NativeModules.MobileInkCanvasViewManager + : null; +export const MobileInkModule = Platform.OS === "android" + ? NativeModules.MobileInkModule + : null; +export const MobileInkBridge = Platform.OS === "ios" + ? NativeModules.MobileInkBridge + : null; + +if (__DEV__) { + if (Platform.OS === "ios" && !MobileInkCanvasViewManager) { + console.warn("[NativeInkCanvas] MobileInkCanvasViewManager not found in NativeModules. Drawing serialization may not work."); + } + if (Platform.OS === "android" && !MobileInkModule) { + console.warn("[NativeInkCanvas] MobileInkModule not found in NativeModules. Drawing serialization may not work."); + } +} + +const LINKING_ERROR = + "The package 'MobileInkCanvasView' doesn't seem to be linked. Make sure: \n\n" + + Platform.select({ ios: "- You have run 'pod install'\n", default: "" }) + + "- You rebuilt the app after installing the package\n" + + "- You are not using Expo Go\n"; + +const mobileInkCanvasViewConfig = UIManager.getViewManagerConfig(COMPONENT_NAME) as + | { NativeProps?: Record } + | null; + +export const supportsRenderBackendProp = + !!mobileInkCanvasViewConfig?.NativeProps && + Object.prototype.hasOwnProperty.call( + mobileInkCanvasViewConfig.NativeProps, + "renderBackend", + ); + +export const MobileInkCanvasViewNative = + mobileInkCanvasViewConfig != null + ? requireNativeComponent(COMPONENT_NAME) + : () => { + throw new Error(LINKING_ERROR); + }; diff --git a/src/native-ink-canvas/notebookBridge.ts b/src/native-ink-canvas/notebookBridge.ts new file mode 100644 index 0000000..a7e8530 --- /dev/null +++ b/src/native-ink-canvas/notebookBridge.ts @@ -0,0 +1,153 @@ +import { Platform } from "react-native"; +import { normalizePagePayloadForNativeLoad } from "../payload"; +import { + MobileInkBridge, + MobileInkModule, +} from "./nativeModules"; + +/** + * Batch export multiple pages to PNG images natively. + * This is much faster than exporting pages one by one because it: + * 1. Creates a single Skia engine and surface (reused for all pages) + * 2. Doesn't switch visible pages (no UI updates) + * 3. Processes all pages in a single native call + * + * @param pagesData Array of page data objects (JSON format with base64 drawing data) + * @param backgroundTypes Array of background type strings per page + * @param width Canvas width in pixels + * @param height Canvas height in pixels + * @param scale Export scale factor (e.g., 2.0 for retina) + * @param pdfBackgroundUri Optional PDF file URI for PDF backgrounds + * @returns Array of base64 PNG data URIs + */ +export async function batchExportPages( + pagesData: string[], + backgroundTypes: string[], + width: number, + height: number, + scale: number = 2.0, + pdfBackgroundUri?: string, + pageIndices?: number[], +): Promise { + if (pagesData.length === 0) { + return []; + } + + __DEV__ && console.log(`[BatchExport] Starting native batch export of ${pagesData.length} pages at ${width}x${height} scale=${scale}`); + const startTime = Date.now(); + + try { + const sanitizedPagesData = pagesData.map((pageData, index) => { + const normalized = normalizePagePayloadForNativeLoad(pageData); + if (!normalized.isValid) { + console.warn(`[BatchExport] Replacing invalid page payload at index ${index} with a blank page (${normalized.reasonCode})`); + return '{"pages":{}}'; + } + return normalized.normalizedPayload || '{"pages":{}}'; + }); + let results: string[]; + + if (Platform.OS === "ios") { + if (!MobileInkBridge) { + throw new Error("MobileInkBridge not found. Please rebuild the app."); + } + // iOS: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri) + results = await MobileInkBridge.batchExportPages( + sanitizedPagesData, + backgroundTypes, + width, + height, + scale, + pdfBackgroundUri || "", + pageIndices || [] + ); + } else { + if (!MobileInkModule) { + throw new Error("MobileInkModule not found. Please rebuild the app."); + } + // Android: batchExportPages(pagesDataArray, backgroundTypes, width, height, scale, pdfUri) + results = await MobileInkModule.batchExportPages( + sanitizedPagesData, + backgroundTypes, + width, + height, + scale, + pdfBackgroundUri || "" + ); + } + + const elapsed = Date.now() - startTime; + const successCount = results.filter(r => r && r.length > 0).length; + __DEV__ && console.log(`[BatchExport] Completed ${successCount}/${pagesData.length} pages in ${elapsed}ms`); + + return results; + } catch (error) { + console.error("[BatchExport] Native batch export failed:", error); + throw error; + } +} + +/** + * Native body-file read + parse. + * + * Reads the notebook body file in C++ via NSJSONSerialization and returns + * the parsed structure to JS. Skips Hermes JSON.parse on a multi-MB string, + * which is the dominant cost of opening a heavy notebook. + * + * Resolves with `null` if the file doesn't exist (caller treats as new file) + * or if the native fast path isn't available (older build). Rejects on real + * read/parse errors so the caller can fall back to the slow path. + * + * iOS-only: MobileInkBridge ships the parser. Android falls through to + * the existing JS-side read+parse path. + */ +export async function readBodyFileParsed( + bodyPath: string, +): Promise | null> { + if (Platform.OS !== "ios" || !MobileInkBridge?.readBodyFileParsed) { + return null; + } + try { + const result = await MobileInkBridge.readBodyFileParsed(bodyPath); + if (result === null || result === undefined) return null; + if (typeof result !== "object") return null; + return result as Record; + } catch (error) { + // Native parse failed -- fall through to JS-side read. + if (__DEV__) { + console.warn("[NativeInkCanvas] readBodyFileParsed failed:", error); + } + return null; + } +} + +export async function composeContinuousWindow( + pagePayloads: string[], + pageHeight: number +): Promise { + if (Platform.OS !== "ios") { + throw new Error("Continuous window composition is only available on iOS."); + } + + if (!MobileInkBridge?.composeContinuousWindow) { + throw new Error("MobileInkBridge.composeContinuousWindow not found. Please rebuild the app."); + } + + return MobileInkBridge.composeContinuousWindow(pagePayloads, pageHeight); +} + +export async function decomposeContinuousWindow( + windowPayload: string, + pageCount: number, + pageHeight: number +): Promise { + if (Platform.OS !== "ios") { + throw new Error("Continuous window decomposition is only available on iOS."); + } + + if (!MobileInkBridge?.decomposeContinuousWindow) { + throw new Error("MobileInkBridge.decomposeContinuousWindow not found. Please rebuild the app."); + } + + return MobileInkBridge.decomposeContinuousWindow(windowPayload, pageCount, pageHeight); +} diff --git a/src/native-ink-canvas/types.ts b/src/native-ink-canvas/types.ts new file mode 100644 index 0000000..35b7a04 --- /dev/null +++ b/src/native-ink-canvas/types.ts @@ -0,0 +1,103 @@ +import type { NativeSyntheticEvent, ViewStyle } from "react-native"; +import type { + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkRenderBackend, +} from "../benchmark"; +import type { NativeSelectionBounds } from "../types"; + +export interface NativeInkCanvasProps { + style?: ViewStyle; + onDrawingChange?: () => void; + onDrawingBegin?: (event: NativeSyntheticEvent<{ x: number; y: number }>) => void; + onSelectionChange?: (event: { nativeEvent: { count: number; bounds?: NativeSelectionBounds | null } }) => void; + onCanvasReady?: () => void; + backgroundType?: string; + pdfBackgroundUri?: string; + renderSuspended?: boolean; + /** iOS only: Chooses the native render path for A/B performance tests. */ + renderBackend?: NativeInkRenderBackend; + /** 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+) */ + onPencilDoubleTap?: (event: NativeSyntheticEvent<{ sequence: number; timestamp: number }>) => void; +} + +export interface NativeInkCanvasRef { + setNativeProps?: (nativeProps: Record) => void; + clear: () => void; + undo: () => void; + redo: () => void; + setTool: (toolType: string, width: number, color: string, eraserMode?: string) => void; + getBase64Data: () => Promise; + loadBase64Data: (base64String: string) => Promise; + /** + * Eagerly release the heavy native state (~13 MB pixel buffer + the + * C++ drawing engine + queued JS callbacks) without waiting for ARC. + * The continuous engine pool calls this only on final pool unmount, + * never for normal page switching. Optional so tests don't have to + * mock it; iOS-only. + */ + releaseEngine?: () => Promise; + /** + * Native-side single-page persistence: tells the engine to serialize its + * current state (one page payload) and write directly to the file at + * `path`. Body bytes never cross the JS<->native bridge. + * + * Useful for paged-mode (Android primary) where one engine = one page. + * Continuous mode should use persistFullNotebookToFile instead so non- + * visible pages are preserved. + */ + persistEngineToFile: (path: string) => Promise; + loadEngineFromFile: (path: string) => Promise; + /** + * Native-side full-notebook autosave (iOS continuous mode). + * + * Reads the existing body file, replaces ONLY the visible window's + * per-page data with the engine's fresh state, writes back atomically. + * Body bytes (which can be many MB) never cross the JS<->native bridge: + * JS only sends the small visible-page-IDs array + lightweight + * pagesMetadata (no data fields). + * + * Returns true on success. Returns false (without throwing) when the + * native fast-path isn't available (older build) so callers can fall + * back to the existing slow path. + */ + persistFullNotebookToFile: (params: { + visiblePageIds: string[]; + pagesMetadata: Array>; + originalCanvasWidth?: number; + pageHeight: number; + bodyPath: string; + }) => Promise; + /** + * Inverse of persistFullNotebookToFile. Reads the body file in native, + * loads visible-window pages into the engine, returns just the slim + * metadata array (no per-page data) plus the originalCanvasWidth. + * + * Returns null (without throwing) when the file is missing, malformed, + * or the native fast-path isn't available. + */ + loadNotebookForVisibleWindow: (params: { + bodyPath: string; + visiblePageIds: string[]; + pageHeight: number; + }) => Promise<{ + success: boolean; + pagesMetadata?: Array>; + originalCanvasWidth?: number | null; + reason?: string; + } | null>; + stageBase64Data?: (base64String: string) => Promise; + presentDeferredLoad?: () => Promise; + getBase64PngData: (scale?: number) => Promise; + getBase64JpegData: (scale?: number, compression?: number) => Promise; + performCopy: () => void; + performPaste: () => void; + performDelete: () => void; + simulatePencilDoubleTap?: () => Promise; + runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; + startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording?: () => Promise; +} diff --git a/src/zoomable-ink-viewport/types.ts b/src/zoomable-ink-viewport/types.ts new file mode 100644 index 0000000..3e6d856 --- /dev/null +++ b/src/zoomable-ink-viewport/types.ts @@ -0,0 +1,82 @@ +import type { ReactNode, RefObject } from "react"; +import type { View, ViewStyle } from "react-native"; + +export interface ZoomableInkViewportRef { + resetZoom: () => void; + resetZoomAnimated: () => void; + isZoomed: () => boolean; + getScale: () => number; + getTransform: () => { + scale: number; + translateX: number; + translateY: number; + containerWidth: number; + containerHeight: number; + }; + setTransform: (nextTransform: { + scale?: number; + translateX?: number; + translateY?: number; + animated?: boolean; + }) => void; +} + +export interface TouchExclusionRect { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface ZoomableInkViewportProps { + children: ReactNode; + style?: ViewStyle; + minScale?: number; + maxScale?: number; + enabled?: boolean; + onZoomChange?: (scale: number) => void; + /** Called when gesture state changes - use to block canvas touches */ + onGestureStateChange?: (isGesturing: boolean) => void; + /** Called when viewport movement starts or stops, including decay/clamp animation. */ + onMotionStateChange?: (isMoving: boolean) => void; + /** Content width - used to calculate pan bounds in landscape */ + contentWidth?: number; + /** Content height - used to calculate pan bounds in landscape */ + contentHeight?: number; + /** Padding around content (e.g., from canvasShell) - affects pan bounds */ + contentPadding?: number; + /** Whether device is in landscape orientation - affects pan bounds alignment */ + isLandscape?: boolean; + /** When true (finger mode), pan requires 2 fingers. When false (pencil mode), 1-finger pan is enabled. */ + fingerDrawingEnabled?: boolean; + /** Width of edge zones (px) where 1-finger touches are rejected to allow page swipe */ + edgeExclusionWidth?: number; + /** Ref attached to the clip container for viewport capture (snipping) */ + viewportRef?: RefObject; + /** Finger-only interactive regions that should block navigation gestures. */ + blockedTouchRects?: TouchExclusionRect[]; + /** When true, pan gestures continue with inertial decay after release. */ + enableMomentumScroll?: boolean; + /** When false, pinch stays available but one/two-finger content pan is disabled. */ + panEnabled?: boolean; + /** Locks small horizontal drift while continuous content is near fit scale. */ + lockHorizontalPanNearFit?: boolean; + /** Reports viewport transform changes for virtualized layouts. */ + onTransformChange?: (transform: { + scale: number; + translateX: number; + translateY: number; + containerWidth: number; + containerHeight: number; + }) => void; + /** Minimum time between JS transform notifications; UI-thread scrolling remains unthrottled. */ + transformNotificationMinIntervalMs?: number; + /** Short finger taps reported in untransformed content coordinates. */ + onContentTap?: (event: { + nativeEvent: { + locationX: number; + locationY: number; + isZoomableContentTap: true; + }; + }) => void; +}