diff --git a/package.json b/package.json index 59a1c23..a650abc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lib", "src/continuous-engine-pool", "src/ContinuousEnginePool.tsx", + "src/infinite-ink-canvas", "src/InfiniteInkCanvas.tsx", "src/native-ink-canvas", "src/NativeInkCanvas.tsx", diff --git a/src/InfiniteInkCanvas.tsx b/src/InfiniteInkCanvas.tsx index a71ccaa..651ec11 100644 --- a/src/InfiniteInkCanvas.tsx +++ b/src/InfiniteInkCanvas.tsx @@ -3,17 +3,10 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from "react"; -import { - StyleProp, - StyleSheet, - Text, - View, - ViewStyle, -} from "react-native"; +import { View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ContinuousEnginePool } from "./ContinuousEnginePool"; import type { @@ -22,143 +15,44 @@ import type { ContinuousEnginePoolSlotRef, ContinuousEnginePoolToolState, } from "./ContinuousEnginePool"; -import NativeInkPageBackground from "./NativeInkPageBackground"; -import type { - NativeInkBenchmarkOptions, - NativeInkBenchmarkRecordingOptions, - NativeInkBenchmarkResult, - NativeInkRenderBackend, -} from "./benchmark"; +import type { NativeInkBenchmarkRecordingOptions } from "./benchmark"; import { aggregateNotebookBenchmarkResults, DEFAULT_NATIVE_INK_RENDER_BACKEND, } from "./benchmark"; import ZoomableInkViewport from "./ZoomableInkViewport"; import type { ZoomableInkViewportRef } from "./ZoomableInkViewport"; +import type { NativeSelectionBounds, NotebookPage } from "./types"; +import { + DEFAULT_CONTENT_PADDING, + DEFAULT_INITIAL_PAGE_COUNT, + DEFAULT_PAGE_HEIGHT, + DEFAULT_PAGE_WIDTH, +} from "./infinite-ink-canvas/constants"; +import { + clonePage, + createInitialPages, + getVisiblePageIndex, + parseNotebookData, +} from "./infinite-ink-canvas/notebookPages"; +import { PageBackgrounds, PageBreaks } from "./infinite-ink-canvas/PageStack"; +import { styles } from "./infinite-ink-canvas/styles"; import type { - NativeInkPencilDoubleTapEvent, - NativeSelectionBounds, - NotebookPage, - SerializedNotebookData, -} from "./types"; + InfiniteInkCanvasProps, + InfiniteInkCanvasRef, + InfiniteInkViewportTransform, +} from "./infinite-ink-canvas/types"; import { getContinuousEnginePoolRange } from "./utils/continuousEnginePool"; import { BLANK_PAGE_PAYLOAD, - createBlankPage, withSingleTrailingBlankPage, } from "./utils/pageGrowth"; -const DEFAULT_PAGE_WIDTH = 820; -const DEFAULT_PAGE_HEIGHT = 1061; -const DEFAULT_INITIAL_PAGE_COUNT = 1; -const DEFAULT_CONTENT_PADDING = 16; - -export type InfiniteInkViewportTransform = { - scale: number; - translateX: number; - translateY: number; - containerWidth: number; - containerHeight: number; -}; - -export type InfiniteInkCanvasRef = { - getNotebookData: () => Promise; - loadNotebookData: (data: SerializedNotebookData | string) => Promise; - addPage: () => Promise; - undo: () => void; - redo: () => void; - clearCurrentPage: () => void; - setTool: (toolState: ContinuousEnginePoolToolState) => void; - resetViewport: (animated?: boolean) => void; - getCurrentPageIndex: () => number; - scrollToPage: (pageIndex: number, animated?: boolean) => void; - runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; - startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; - stopBenchmarkRecording?: () => Promise; -}; - -export type InfiniteInkCanvasProps = { - style?: StyleProp; - initialData?: SerializedNotebookData | string; - initialPageCount?: number; - pageWidth?: number; - pageHeight?: number; - backgroundType?: string; - renderBackend?: NativeInkRenderBackend; - pdfBackgroundBaseUri?: string; - fingerDrawingEnabled?: boolean; - toolState: ContinuousEnginePoolToolState; - minScale?: number; - maxScale?: number; - contentPadding?: number; - showPageLabels?: boolean; - onReady?: () => void; - onDrawingChange?: (pageId: string) => void; - onSelectionChange?: ( - pageId: string, - count: number, - bounds: NativeSelectionBounds | null, - ) => void; - onCurrentPageChange?: (pageIndex: number) => void; - onPagesChange?: (pages: NotebookPage[]) => void; - onMotionStateChange?: (isMoving: boolean) => void; - onPencilDoubleTap?: (event: NativeInkPencilDoubleTapEvent) => void; -}; - -const clonePage = (page: NotebookPage, pageIndex: number): NotebookPage => ({ - ...page, - id: page.id || `page-${pageIndex + 1}`, - title: page.title || `Page ${pageIndex + 1}`, - data: page.data || BLANK_PAGE_PAYLOAD, - rotation: page.rotation ?? 0, -}); - -const parseNotebookData = ( - data: SerializedNotebookData | string | undefined, -): SerializedNotebookData | null => { - if (!data) { - return null; - } - - if (typeof data !== "string") { - return data; - } - - const parsed = JSON.parse(data) as SerializedNotebookData; - if (!Array.isArray(parsed.pages)) { - throw new Error("Invalid mobile-ink notebook data: pages must be an array."); - } - return parsed; -}; - -const createInitialPages = ( - initialData: SerializedNotebookData | string | undefined, - initialPageCount: number, -) => { - const parsed = parseNotebookData(initialData); - if (parsed?.pages.length) { - return withSingleTrailingBlankPage(parsed.pages.map(clonePage)); - } - - return withSingleTrailingBlankPage(Array.from( - { length: Math.max(1, initialPageCount) }, - (_, pageIndex) => createBlankPage(pageIndex), - )); -}; - -const getVisiblePageIndex = ( - transform: InfiniteInkViewportTransform, - pageHeight: number, - 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); - return Math.max( - 0, - Math.min(pageCount - 1, Math.floor(visibleCenterY / pageHeight)), - ); -}; + +export type { + InfiniteInkCanvasProps, + InfiniteInkCanvasRef, + InfiniteInkViewportTransform, +} from "./infinite-ink-canvas/types"; function InfiniteInkCanvasImpl( { @@ -541,43 +435,6 @@ function InfiniteInkCanvasImpl( void assignEnginesToPage(currentPageIndexRef.current); }, [assignEnginesToPage, onReady]); - const pageBackgrounds = useMemo(() => pages.map((page, pageIndex) => { - const pdfBackgroundUri = backgroundType === "pdf" && pdfBackgroundBaseUri - ? `${pdfBackgroundBaseUri}#page=${page.pdfPageNumber || pageIndex + 1}` - : undefined; - - return ( - - - {showPageLabels ? ( - {pageIndex + 1} - ) : null} - - ); - }), [ - backgroundType, - pageHeight, - pageWidth, - pages, - pdfBackgroundBaseUri, - showPageLabels, - ]); - return ( - {pageBackgrounds} + - {pages.slice(1).map((page, pageIndex) => ( - - ))} + @@ -643,43 +498,4 @@ export const InfiniteInkCanvas = forwardRef { - onGestureStateChange?.(true); - }, [onGestureStateChange]); - - const notifyGestureEnd = useCallback(() => { - onGestureStateChange?.(false); - }, [onGestureStateChange]); - - const notifyMotionChange = useCallback((isMoving: boolean) => { - onMotionStateChange?.(isMoving); - }, [onMotionStateChange]); - - const notifyTransformChange = useCallback(( - nextScale: number, - nextTranslateX: number, - nextTranslateY: number, - nextContainerWidth: number, - nextContainerHeight: number - ) => { - onTransformChange?.({ - scale: nextScale, - translateX: nextTranslateX, - translateY: nextTranslateY, - containerWidth: nextContainerWidth, - containerHeight: nextContainerHeight, - }); - }, [onTransformChange]); - const handleLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; const prevHeight = containerHeight.value; @@ -182,641 +153,48 @@ const ZoomableInkViewport = forwardRef { - 'worklet'; - return Math.min(Math.max(value, min), max); - }; - - const isTouchBlocked = (x: number, y: number): boolean => { - 'worklet'; - const currentScale = scale.value > 0 ? scale.value : 1; - const contentX = (x - translateX.value) / currentScale; - const contentY = (y - translateY.value) / currentScale; - - for (const rect of blockedRects.value) { - if ( - contentX >= rect.left && - contentX <= rect.right && - contentY >= rect.top && - contentY <= rect.bottom - ) { - return true; - } - } - - return false; - }; - - /** - * Calculate pan bounds based on content vs container size - * Allows panning when content exceeds container (e.g., landscape mode) - * - * When zoomed, allows enough range to see any corner of the content. - * This enables proper focal-point zooming where the pinch point stays under fingers. - * - * Layout differences: - * - Portrait: content is centered (justifyContent: center) - symmetric bounds - * - Landscape: content is top-aligned (justifyContent: flex-start) - asymmetric bounds - * - * Content has padding (e.g., 16px from canvasShell) that must be accounted for. - */ - const getTranslationBounds = ( - currentScale: number, - containerW: number, - containerH: number, - contentWidth: number, - contentHeight: number, - padding: number, - landscape: boolean - ): { minX: number; maxX: number; minY: number; maxY: number } => { - 'worklet'; - // Scaled content dimensions. The actual page stack is centered - // horizontally in the gesture viewport and top-aligned with - // padding vertically in continuous mode. - const scaledContentW = contentWidth * currentScale; - const scaledContentH = contentHeight * currentScale; - const scaledPadding = padding * currentScale; - const totalVisualH = scaledContentH + scaledPadding * 2; - - // For X, allow the page edge nearest the pinch focal point to - // remain anchored as long as the page itself stays inside the - // viewport. When zoomed beyond the viewport, the same formula - // becomes the usual "no blank outside the document" range. - const maxTxForHorizontalEdges = - containerW > 0 && scaledContentW > 0 - ? Math.abs(scaledContentW - containerW) / 2 - : 0; - - let minX = -maxTxForHorizontalEdges; - let maxX = maxTxForHorizontalEdges; - let minY = 0; - let maxY = 0; - - if (landscape) { - // LANDSCAPE: content is TOP-ALIGNED (flex-start) - // Keep the page stack edges, not the shell edges, pinned to the - // same padding margin. This lets top-edge pinches zoom into the - // top of the page instead of being clamped inward immediately. - if (totalVisualH > containerH) { - const centerY = containerH / 2; - const topPinnedY = - padding - centerY - currentScale * (padding - centerY); - const bottomPinnedY = - containerH - - padding - - centerY - - currentScale * (padding + contentHeight - centerY); - minY = Math.min(bottomPinnedY, topPinnedY); - maxY = Math.max(bottomPinnedY, topPinnedY); - } - } else { - // PORTRAIT: content is CENTERED - // Symmetric bounds around center - const overflow = Math.max(0, totalVisualH - containerH); - minY = -overflow / 2; - maxY = overflow / 2; - } - - return { minX, maxX, minY, maxY }; - }; - - const clampTranslation = ( - tx: number, - ty: number, - currentScale: number, - containerW: number, - containerH: number, - contentWidth: number, - contentHeight: number, - padding: number, - landscape: boolean - ): { x: number; y: number } => { - 'worklet'; - const bounds = getTranslationBounds( - currentScale, - containerW, - containerH, - contentWidth, - contentHeight, - padding, - landscape - ); - - return { - x: clamp(tx, bounds.minX, bounds.maxX), - y: clamp(ty, bounds.minY, bounds.maxY), - }; - }; - - const shouldLockHorizontalTranslation = (currentScale: number): boolean => { - 'worklet'; - const scaledContentWidth = contentW.value * currentScale; - return ( - lockHorizontalPanNearFitValue.value && - isLandscapeMode.value && - ( - currentScale <= HORIZONTAL_LOCK_MAX_SCALE || - scaledContentWidth <= containerWidth.value - ) - ); - }; - - const stopMomentumAnimations = () => { - 'worklet'; - cancelAnimation(scale); - cancelAnimation(translateX); - cancelAnimation(translateY); - isDecayingX.value = false; - isDecayingY.value = false; - isClampingX.value = false; - isClampingY.value = false; - savedScale.value = scale.value; - savedTranslateX.value = translateX.value; - savedTranslateY.value = translateY.value; - }; - - const syncMotionState = () => { - 'worklet'; - const nextIsMoving = - isGesturingRef.value || - isDecayingX.value || - isDecayingY.value || - isClampingX.value || - isClampingY.value; - - if (isMotionActive.value === nextIsMoving) { - return; - } - - isMotionActive.value = nextIsMoving; - runOnJS(notifyMotionChange)(nextIsMoving); - }; - - useAnimatedReaction( - () => ({ - scale: scale.value, - translateX: translateX.value, - translateY: translateY.value, - containerWidth: containerWidth.value, - containerHeight: containerHeight.value, - }), - (nextTransform, previousTransform) => { - const containerChanged = - !previousTransform || - nextTransform.containerWidth !== previousTransform.containerWidth || - nextTransform.containerHeight !== previousTransform.containerHeight; - - if ( - previousTransform && - nextTransform.scale === previousTransform.scale && - nextTransform.translateX === previousTransform.translateX && - nextTransform.translateY === previousTransform.translateY && - !containerChanged - ) { - return; - } - - if (!containerChanged && transformNotificationMinIntervalMs > 0) { - const now = Date.now(); - if (now - lastTransformNotificationTs.value < transformNotificationMinIntervalMs) { - return; - } - lastTransformNotificationTs.value = now; - } else { - lastTransformNotificationTs.value = Date.now(); - } - - runOnJS(notifyTransformChange)( - nextTransform.scale, - nextTransform.translateX, - nextTransform.translateY, - nextTransform.containerWidth, - nextTransform.containerHeight - ); - } - ); - - /** - * Pinch gesture for zooming - always requires 2 fingers - */ - const pinchGesture = useMemo(() => { - return Gesture.Pinch() - .enabled(enabled) - .onTouchesDown(() => { - 'worklet'; - stopMomentumAnimations(); - syncMotionState(); - }) - .onStart((event) => { - 'worklet'; - stopMomentumAnimations(); - // Don't claim "actively scaling" yet -- the gesture just - // started, scale ratio is 1.0. We only mark active in - // onUpdate when scale actually deviates between frames. - isPinchActive.value = false; - lastPinchEventScale.value = 1; - isGesturingRef.value = true; - runOnJS(notifyGestureStart)(); - syncMotionState(); - savedScale.value = scale.value; - savedTranslateX.value = translateX.value; - savedTranslateY.value = translateY.value; - focalX.value = event.focalX; - focalY.value = event.focalY; - }) - .onUpdate((event) => { - 'worklet'; - // Per-frame scale delta. If fingers aren't actually - // changing distance this frame, the user is dragging -- - // unlock pan and skip translating from pinch. - const scaleDelta = Math.abs(event.scale - lastPinchEventScale.value); - if (scaleDelta < 0.0005) { - isPinchActive.value = false; - lastPinchEventScale.value = event.scale; - return; - } - - isPinchActive.value = true; - - // Per-frame scale ratio (relative to last frame, NOT to - // gesture start). Composes correctly with pan, which also - // updates per-frame deltas. - const frameRatio = event.scale / lastPinchEventScale.value; - const prevScale = scale.value; - const newScale = clamp(prevScale * frameRatio, minScale, maxScale); - const centerX = containerWidth.value / 2; - const centerY = containerHeight.value / 2; - - // Focal point zoom -- keep the content point currently under - // the fingers anchored to the same SCREEN position across - // this single frame. - // - // event.focalX/Y are reported in screen (GestureDetector - // view) coordinates from a stable, untransformed wrapper, so - // RNGH does NOT apply an inverse transform to the focal - // coords. Treating them as content coords gives wrong math - // whenever translateX/Y is non-zero (i.e. as soon as the - // user has scrolled or panned anywhere, which is common in - // continuous PDF mode). - // - // Derivation: - // screen = center + translate + scale * (content - center) - // focalContent = (focalScreen - center - prevTX) / prevScale + center - // For focalContent to stay at focalScreen after the zoom: - // focalScreen = center + newTX + newScale * (focalContent - center) - // Solve for newTX, simplify: - // newTX = prevTX + ((prevScale - newScale) / prevScale) - // * (focalScreen - center - prevTX) - // - // The previous formula was missing both the / prevScale - // ratio AND the - prevTX correction, which made every zoom - // jump the view (proportional to the current pan offset). - const dxFromCenter = event.focalX - centerX - translateX.value; - const dyFromCenter = event.focalY - centerY - translateY.value; - const scaleRatio = (prevScale - newScale) / prevScale; - let newTranslateX = translateX.value + scaleRatio * dxFromCenter; - let newTranslateY = translateY.value + scaleRatio * dyFromCenter; - - const clampedTranslation = clampTranslation( - newTranslateX, - newTranslateY, - newScale, - containerWidth.value, - containerHeight.value, - contentW.value, - contentH.value, - contentPad.value, - isLandscapeMode.value - ); - const isZoomingOut = newScale < prevScale; - newTranslateX = shouldLockHorizontalTranslation(newScale) && isZoomingOut - ? 0 - : clampedTranslation.x; - newTranslateY = clampedTranslation.y; - - scale.value = newScale; - // Clamp during pinch so zoom-out cannot expose empty space - // around the document. While zooming in, fitting content can - // still slide within the viewport so edge pinches stay - // anchored to the page edge instead of correcting inward. - translateX.value = newTranslateX; - translateY.value = newTranslateY; - - lastPinchEventScale.value = event.scale; - }) - .onEnd(() => { - 'worklet'; - isPinchActive.value = false; - isGesturingRef.value = false; - runOnJS(notifyGestureEnd)(); - - // No release clamp animation: pinch updates already keep the - // transform inside document bounds, so an extra animation here - // would feel like a second, user-visible correction after the - // fingers lift. - - savedScale.value = scale.value; - savedTranslateX.value = translateX.value; - savedTranslateY.value = translateY.value; - - syncMotionState(); - - if (onZoomChange) { - runOnJS(onZoomChange)(scale.value); - } - }) - .onFinalize(() => { - 'worklet'; - if (isGesturingRef.value) { - isGesturingRef.value = false; - runOnJS(notifyGestureEnd)(); - syncMotionState(); - } - }); - }, [enabled, minScale, maxScale, onZoomChange, notifyGestureStart, notifyGestureEnd, notifyMotionChange]); - - /** - * Pan gesture for panning zoomed/overflowing content - * - * - Finger mode (fingerDrawingEnabled=true): 2-finger pan only, 1-finger draws - * - Pencil mode (fingerDrawingEnabled=false): 1-finger pan in center area, - * edge-zone touches rejected (fall through to page swipe), stylus draws - */ - const panGesture = useMemo(() => { - const gesture = Gesture.Pan() - .enabled(enabled && panEnabled) - .minDistance(0) - .onTouchesDown((event, stateManager) => { - 'worklet'; - stopMomentumAnimations(); - syncMotionState(); - - if (!fingerDrawingEnabled) { - // Reject stylus touches - let them reach the drawing canvas - // PointerType.STYLUS = 1 - if (event.pointerType === 1) { - stateManager.fail(); - return; - } - - // Reject touches in edge zones - let them reach page swipe handler - if (edgeExclusionWidth > 0) { - const touch = event.allTouches[0]; - if (touch && ( - touch.x < edgeExclusionWidth || - touch.x > containerWidth.value - edgeExclusionWidth - )) { - stateManager.fail(); - return; - } - } - - const touch = event.allTouches[0]; - if (touch && isTouchBlocked(touch.x, touch.y)) { - stateManager.fail(); - return; - } - - // Valid center-area finger touch — DO NOT activate yet. Defer activation - // until the user actually starts moving (handled in onTouchesMove below). - // If we activate here, a pure tap (touch + release with no movement) is - // claimed by this gesture and never propagates to the inner - // , which is what - // clears figure/text-box selection on tap-empty-canvas. The user-visible - // bug was "I can't tap to deselect, I have to use pencil." - } - }) - .onTouchesMove((_event, stateManager) => { - 'worklet'; - // First movement after a valid touch-down promotes this pan to ACTIVE. - // (Stylus / edge / blocked touches were already failed in onTouchesDown, - // so we never reach here in those cases.) - if (!fingerDrawingEnabled) { - stateManager.activate(); - } - }); - - if (fingerDrawingEnabled) { - // Finger mode: only 2-finger pan (single finger is for drawing) - gesture.minPointers(2).maxPointers(2); - } else { - // Pencil mode: 1 or 2 finger pan (stylus is for drawing) - gesture - .minPointers(1) - .maxPointers(2) - .manualActivation(true); - } - - gesture - .onStart(() => { - 'worklet'; - stopMomentumAnimations(); - isGesturingRef.value = true; - runOnJS(notifyGestureStart)(); - syncMotionState(); - savedTranslateX.value = translateX.value; - savedTranslateY.value = translateY.value; - lastPanTranslationX.value = 0; - lastPanTranslationY.value = 0; - }) - .onUpdate((event) => { - 'worklet'; - // Yield to pinch only while it is *actively scaling* this - // frame. (isPinchActive is now per-frame -- it goes false - // during a pure 2-finger drag, so pan can run.) - if (isPinchActive.value) { - // Even though we're not applying translation, keep the - // per-frame baseline current so when pan resumes next - // frame we don't double-apply accumulated delta. - lastPanTranslationX.value = event.translationX; - lastPanTranslationY.value = event.translationY; - return; - } - - // Use per-frame delta (translationX since last frame) instead - // of cumulative. Cumulative would conflict with pinch's - // focal-point adjustments to translateX/Y on prior frames -- - // pan would snap the content back to (savedTranslate + - // cumulative) and undo the zoom translation. - const dx = event.translationX - lastPanTranslationX.value; - const dy = event.translationY - lastPanTranslationY.value; - lastPanTranslationX.value = event.translationX; - lastPanTranslationY.value = event.translationY; - const newTranslateX = translateX.value + dx; - const newTranslateY = translateY.value + dy; - - const bounds = getTranslationBounds( - scale.value, - containerWidth.value, - containerHeight.value, - contentW.value, - contentH.value, - contentPad.value, - isLandscapeMode.value - ); - - // If we're entering this frame already out of bounds (which - // happens after pinch-zoom near the top of the screen leaves - // translateY > maxY=0 in landscape continuous mode), snapping - // to bounds NOW would yank the content the user just zoomed - // into off-screen ("snaps down" in the user's words). Allow - // free movement back TOWARD bounds; only clamp the AXIS that's - // moving further out of bounds. - let nextX = newTranslateX; - let nextY = newTranslateY; - const xWasInBounds = - translateX.value >= bounds.minX && translateX.value <= bounds.maxX; - const yWasInBounds = - translateY.value >= bounds.minY && translateY.value <= bounds.maxY; - - if (shouldLockHorizontalTranslation(scale.value)) { - nextX = 0; - } else if (xWasInBounds) { - nextX = clamp(newTranslateX, bounds.minX, bounds.maxX); - } else if (translateX.value > bounds.maxX) { - // Currently overshooting maxX. Allow movement toward bounds - // (smaller X), block movement further past bounds. - nextX = Math.min(newTranslateX, translateX.value); - } else { - // Currently below minX. Allow movement up, block movement down. - nextX = Math.max(newTranslateX, translateX.value); - } - - if (yWasInBounds) { - nextY = clamp(newTranslateY, bounds.minY, bounds.maxY); - } else if (translateY.value > bounds.maxY) { - nextY = Math.min(newTranslateY, translateY.value); - } else { - nextY = Math.max(newTranslateY, translateY.value); - } - - translateX.value = nextX; - translateY.value = nextY; - }) - .onEnd((event) => { - 'worklet'; - isGesturingRef.value = false; - runOnJS(notifyGestureEnd)(); - - if (enableMomentumScroll && !isPinchActive.value) { - const bounds = getTranslationBounds( - scale.value, - containerWidth.value, - containerHeight.value, - contentW.value, - contentH.value, - contentPad.value, - isLandscapeMode.value - ); - - // If translateY is currently out of bounds (e.g. because - // the user just finished a pinch zoom that anchored - // content above the maxY=0 ceiling in landscape continuous - // mode), withDecay's clamp would IMMEDIATELY snap Y to the - // nearest bound -- visible to the user as "the view jumps - // away when I let go." Skip decay in that case so the - // value stays where the pinch placed it; the user's next - // pan will gently bring it back via per-frame clamping. - const yIsOutOfBounds = - translateY.value > bounds.maxY || translateY.value < bounds.minY; - const xIsOutOfBounds = - translateX.value > bounds.maxX || translateX.value < bounds.minX; - - const shouldDecayX = - !shouldLockHorizontalTranslation(scale.value) && - !xIsOutOfBounds && - Math.abs(event.velocityX) > 20 && - bounds.minX !== bounds.maxX; - const shouldDecayY = - !yIsOutOfBounds && - Math.abs(event.velocityY) > 20 && - bounds.minY !== bounds.maxY; - - if (shouldDecayX) { - isDecayingX.value = true; - syncMotionState(); - translateX.value = withDecay({ - velocity: event.velocityX, - clamp: [bounds.minX, bounds.maxX], - }, () => { - 'worklet'; - isDecayingX.value = false; - syncMotionState(); - }); - } - - if (shouldDecayY) { - isDecayingY.value = true; - syncMotionState(); - translateY.value = withDecay({ - velocity: event.velocityY, - clamp: [bounds.minY, bounds.maxY], - }, () => { - 'worklet'; - isDecayingY.value = false; - syncMotionState(); - }); - } - } - - savedTranslateX.value = translateX.value; - savedTranslateY.value = translateY.value; - syncMotionState(); - }) - .onFinalize(() => { - 'worklet'; - // Ensure we always notify end, even on cancel - if (isGesturingRef.value) { - isGesturingRef.value = false; - runOnJS(notifyGestureEnd)(); - syncMotionState(); - } - }); - - return gesture; - }, [enableMomentumScroll, enabled, fingerDrawingEnabled, edgeExclusionWidth, notifyGestureStart, notifyGestureEnd, notifyMotionChange, panEnabled]); - - const notifyContentTap = useCallback((locationX: number, locationY: number) => { - onContentTap?.({ - nativeEvent: { - locationX, - locationY, - isZoomableContentTap: true, - }, - }); - }, [onContentTap]); - - const tapGesture = useMemo(() => { - return Gesture.Tap() - .enabled(enabled && !!onContentTap) - .maxDistance(10) - .maxDuration(350) - .onTouchesDown((event, stateManager) => { - 'worklet'; - if (event.pointerType === 1) { - stateManager.fail(); - } - }) - .onEnd((event, success) => { - 'worklet'; - if (!success) { - return; - } - - const currentScale = scale.value > 0 ? scale.value : 1; - const locationX = (event.x - translateX.value) / currentScale; - const locationY = (event.y - translateY.value) / currentScale; - runOnJS(notifyContentTap)(locationX, locationY); - }); - }, [enabled, notifyContentTap, onContentTap, scale, translateX, translateY]); - - /** - * Compose gestures - pinch and pan run simultaneously - * No double-tap (it delays stylus input by 250ms) - */ - const composedGesture = useMemo(() => { - return Gesture.Simultaneous(pinchGesture, panGesture, tapGesture); - }, [pinchGesture, panGesture, tapGesture]); + const composedGesture = useZoomableViewportGestures({ + enabled, + minScale, + maxScale, + onZoomChange, + onGestureStateChange, + onMotionStateChange, + fingerDrawingEnabled, + edgeExclusionWidth, + enableMomentumScroll, + panEnabled, + onTransformChange, + transformNotificationMinIntervalMs, + onContentTap, + containerWidth, + containerHeight, + contentW, + contentH, + contentPad, + isLandscapeMode, + lockHorizontalPanNearFitValue, + blockedRects, + scale, + translateX, + translateY, + savedScale, + savedTranslateX, + savedTranslateY, + focalX, + focalY, + isGesturingRef, + isPinchActive, + lastPinchEventScale, + lastPanTranslationX, + lastPanTranslationY, + isDecayingX, + isDecayingY, + isClampingX, + isClampingY, + isMotionActive, + lastTransformNotificationTs, + }); const resetZoom = useCallback(() => { cancelAnimation(scale); @@ -863,13 +241,13 @@ const ZoomableInkViewport = forwardRef + {pages.map((page, pageIndex) => { + const pdfBackgroundUri = backgroundType === "pdf" && pdfBackgroundBaseUri + ? `${pdfBackgroundBaseUri}#page=${page.pdfPageNumber || pageIndex + 1}` + : undefined; + + return ( + + + {showPageLabels ? ( + {pageIndex + 1} + ) : null} + + ); + })} + + ); +}); + +export type PageBreaksProps = { + pages: NotebookPage[]; + pageHeight: number; +}; + +export const PageBreaks = memo(function PageBreaks({ + pages, + pageHeight, +}: PageBreaksProps) { + return ( + <> + {pages.slice(1).map((page, pageIndex) => ( + + ))} + + ); +}); diff --git a/src/infinite-ink-canvas/constants.ts b/src/infinite-ink-canvas/constants.ts new file mode 100644 index 0000000..ecf8918 --- /dev/null +++ b/src/infinite-ink-canvas/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_PAGE_WIDTH = 820; +export const DEFAULT_PAGE_HEIGHT = 1061; +export const DEFAULT_INITIAL_PAGE_COUNT = 1; +export const DEFAULT_CONTENT_PADDING = 16; diff --git a/src/infinite-ink-canvas/notebookPages.ts b/src/infinite-ink-canvas/notebookPages.ts new file mode 100644 index 0000000..fb9f3b1 --- /dev/null +++ b/src/infinite-ink-canvas/notebookPages.ts @@ -0,0 +1,63 @@ +import type { NotebookPage, SerializedNotebookData } from "../types"; +import { + BLANK_PAGE_PAYLOAD, + createBlankPage, + withSingleTrailingBlankPage, +} from "../utils/pageGrowth"; +import type { InfiniteInkViewportTransform } from "./types"; + +export const clonePage = (page: NotebookPage, pageIndex: number): NotebookPage => ({ + ...page, + id: page.id || `page-${pageIndex + 1}`, + title: page.title || `Page ${pageIndex + 1}`, + data: page.data || BLANK_PAGE_PAYLOAD, + rotation: page.rotation ?? 0, +}); + +export const parseNotebookData = ( + data: SerializedNotebookData | string | undefined, +): SerializedNotebookData | null => { + if (!data) { + return null; + } + + if (typeof data !== "string") { + return data; + } + + const parsed = JSON.parse(data) as SerializedNotebookData; + if (!Array.isArray(parsed.pages)) { + throw new Error("Invalid mobile-ink notebook data: pages must be an array."); + } + return parsed; +}; + +export const createInitialPages = ( + initialData: SerializedNotebookData | string | undefined, + initialPageCount: number, +) => { + const parsed = parseNotebookData(initialData); + if (parsed?.pages.length) { + return withSingleTrailingBlankPage(parsed.pages.map(clonePage)); + } + + return withSingleTrailingBlankPage(Array.from( + { length: Math.max(1, initialPageCount) }, + (_, pageIndex) => createBlankPage(pageIndex), + )); +}; + +export const getVisiblePageIndex = ( + transform: InfiniteInkViewportTransform, + pageHeight: number, + 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); + return Math.max( + 0, + Math.min(pageCount - 1, Math.floor(visibleCenterY / pageHeight)), + ); +}; diff --git a/src/infinite-ink-canvas/styles.ts b/src/infinite-ink-canvas/styles.ts new file mode 100644 index 0000000..6a3a537 --- /dev/null +++ b/src/infinite-ink-canvas/styles.ts @@ -0,0 +1,40 @@ +import { StyleSheet } from "react-native"; + +export const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: "#F4F6F8", + }, + viewport: { + flex: 1, + overflow: "hidden", + }, + canvasShell: { + flex: 1, + alignItems: "center", + justifyContent: "flex-start", + zIndex: 1, + }, + page: { + position: "absolute", + left: 0, + overflow: "hidden", + backgroundColor: "#FFFFFF", + }, + pageLabel: { + position: "absolute", + top: 12, + right: 14, + color: "rgba(71, 85, 105, 0.32)", + fontSize: 13, + fontWeight: "700", + }, + pageBreak: { + position: "absolute", + left: 0, + right: 0, + height: StyleSheet.hairlineWidth, + backgroundColor: "rgba(100, 116, 139, 0.18)", + zIndex: 3, + }, +}); diff --git a/src/infinite-ink-canvas/types.ts b/src/infinite-ink-canvas/types.ts new file mode 100644 index 0000000..aa9c612 --- /dev/null +++ b/src/infinite-ink-canvas/types.ts @@ -0,0 +1,66 @@ +import type { StyleProp, ViewStyle } from "react-native"; +import type { ContinuousEnginePoolToolState } from "../ContinuousEnginePool"; +import type { + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkRenderBackend, +} from "../benchmark"; +import type { + NativeInkPencilDoubleTapEvent, + NativeSelectionBounds, + NotebookPage, + SerializedNotebookData, +} from "../types"; + +export type InfiniteInkViewportTransform = { + scale: number; + translateX: number; + translateY: number; + containerWidth: number; + containerHeight: number; +}; + +export type InfiniteInkCanvasRef = { + getNotebookData: () => Promise; + loadNotebookData: (data: SerializedNotebookData | string) => Promise; + addPage: () => Promise; + undo: () => void; + redo: () => void; + clearCurrentPage: () => void; + setTool: (toolState: ContinuousEnginePoolToolState) => void; + resetViewport: (animated?: boolean) => void; + getCurrentPageIndex: () => number; + scrollToPage: (pageIndex: number, animated?: boolean) => void; + runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; + startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording?: () => Promise; +}; + +export type InfiniteInkCanvasProps = { + style?: StyleProp; + initialData?: SerializedNotebookData | string; + initialPageCount?: number; + pageWidth?: number; + pageHeight?: number; + backgroundType?: string; + renderBackend?: NativeInkRenderBackend; + pdfBackgroundBaseUri?: string; + fingerDrawingEnabled?: boolean; + toolState: ContinuousEnginePoolToolState; + minScale?: number; + maxScale?: number; + contentPadding?: number; + showPageLabels?: boolean; + onReady?: () => void; + onDrawingChange?: (pageId: string) => void; + onSelectionChange?: ( + pageId: string, + count: number, + bounds: NativeSelectionBounds | null, + ) => void; + onCurrentPageChange?: (pageIndex: number) => void; + onPagesChange?: (pages: NotebookPage[]) => void; + onMotionStateChange?: (isMoving: boolean) => void; + onPencilDoubleTap?: (event: NativeInkPencilDoubleTapEvent) => void; +}; diff --git a/src/zoomable-ink-viewport/geometry.ts b/src/zoomable-ink-viewport/geometry.ts new file mode 100644 index 0000000..d198d34 --- /dev/null +++ b/src/zoomable-ink-viewport/geometry.ts @@ -0,0 +1,110 @@ +export const HORIZONTAL_LOCK_MAX_SCALE = 1.12; + +export type TranslationBounds = { + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +export const clamp = (value: number, min: number, max: number): number => { + "worklet"; + return Math.min(Math.max(value, min), max); +}; + +/** + * Calculates pan bounds from the current content, viewport, zoom, and layout mode. + */ +export const getTranslationBounds = ( + currentScale: number, + containerW: number, + containerH: number, + contentWidth: number, + contentHeight: number, + padding: number, + landscape: boolean +): TranslationBounds => { + "worklet"; + const scaledContentW = contentWidth * currentScale; + const scaledContentH = contentHeight * currentScale; + const scaledPadding = padding * currentScale; + const totalVisualH = scaledContentH + scaledPadding * 2; + + const maxTxForHorizontalEdges = + containerW > 0 && scaledContentW > 0 + ? Math.abs(scaledContentW - containerW) / 2 + : 0; + + let minX = -maxTxForHorizontalEdges; + let maxX = maxTxForHorizontalEdges; + let minY = 0; + let maxY = 0; + + if (landscape) { + if (totalVisualH > containerH) { + const centerY = containerH / 2; + const topPinnedY = + padding - centerY - currentScale * (padding - centerY); + const bottomPinnedY = + containerH - + padding - + centerY - + currentScale * (padding + contentHeight - centerY); + minY = Math.min(bottomPinnedY, topPinnedY); + maxY = Math.max(bottomPinnedY, topPinnedY); + } + } else { + const overflow = Math.max(0, totalVisualH - containerH); + minY = -overflow / 2; + maxY = overflow / 2; + } + + return { minX, maxX, minY, maxY }; +}; + +export const clampTranslation = ( + tx: number, + ty: number, + currentScale: number, + containerW: number, + containerH: number, + contentWidth: number, + contentHeight: number, + padding: number, + landscape: boolean +): { x: number; y: number } => { + "worklet"; + const bounds = getTranslationBounds( + currentScale, + containerW, + containerH, + contentWidth, + contentHeight, + padding, + landscape + ); + + return { + x: clamp(tx, bounds.minX, bounds.maxX), + y: clamp(ty, bounds.minY, bounds.maxY), + }; +}; + +export const shouldLockHorizontalTranslation = ( + currentScale: number, + contentWidth: number, + containerWidth: number, + lockHorizontalPanNearFit: boolean, + isLandscape: boolean, +): boolean => { + "worklet"; + const scaledContentWidth = contentWidth * currentScale; + return ( + lockHorizontalPanNearFit && + isLandscape && + ( + currentScale <= HORIZONTAL_LOCK_MAX_SCALE || + scaledContentWidth <= containerWidth + ) + ); +}; diff --git a/src/zoomable-ink-viewport/useZoomableViewportGestures.ts b/src/zoomable-ink-viewport/useZoomableViewportGestures.ts new file mode 100644 index 0000000..a621279 --- /dev/null +++ b/src/zoomable-ink-viewport/useZoomableViewportGestures.ts @@ -0,0 +1,647 @@ +import { useCallback, useMemo } from "react"; +import { Gesture } from "react-native-gesture-handler"; +import { + cancelAnimation, + runOnJS, + useAnimatedReaction, + withDecay, +} from "react-native-reanimated"; +import type { SharedValue } from "react-native-reanimated"; +import { + clamp, + clampTranslation, + getTranslationBounds, + shouldLockHorizontalTranslation as shouldLockHorizontalTranslationForLayout, +} from "./geometry"; +import type { + TouchExclusionRect, + ZoomableInkViewportProps, +} from "./types"; + +type UseZoomableViewportGesturesParams = { + enabled: boolean; + minScale: number; + maxScale: number; + onZoomChange?: ZoomableInkViewportProps["onZoomChange"]; + onGestureStateChange?: ZoomableInkViewportProps["onGestureStateChange"]; + onMotionStateChange?: ZoomableInkViewportProps["onMotionStateChange"]; + fingerDrawingEnabled: boolean; + edgeExclusionWidth: number; + enableMomentumScroll: boolean; + panEnabled: boolean; + onTransformChange?: ZoomableInkViewportProps["onTransformChange"]; + transformNotificationMinIntervalMs: number; + onContentTap?: ZoomableInkViewportProps["onContentTap"]; + containerWidth: SharedValue; + containerHeight: SharedValue; + contentW: SharedValue; + contentH: SharedValue; + contentPad: SharedValue; + isLandscapeMode: SharedValue; + lockHorizontalPanNearFitValue: SharedValue; + blockedRects: SharedValue; + scale: SharedValue; + translateX: SharedValue; + translateY: SharedValue; + savedScale: SharedValue; + savedTranslateX: SharedValue; + savedTranslateY: SharedValue; + focalX: SharedValue; + focalY: SharedValue; + isGesturingRef: SharedValue; + isPinchActive: SharedValue; + lastPinchEventScale: SharedValue; + lastPanTranslationX: SharedValue; + lastPanTranslationY: SharedValue; + isDecayingX: SharedValue; + isDecayingY: SharedValue; + isClampingX: SharedValue; + isClampingY: SharedValue; + isMotionActive: SharedValue; + lastTransformNotificationTs: SharedValue; +}; + +export const useZoomableViewportGestures = ({ + enabled, + minScale, + maxScale, + onZoomChange, + onGestureStateChange, + onMotionStateChange, + fingerDrawingEnabled, + edgeExclusionWidth, + enableMomentumScroll, + panEnabled, + onTransformChange, + transformNotificationMinIntervalMs, + onContentTap, + containerWidth, + containerHeight, + contentW, + contentH, + contentPad, + isLandscapeMode, + lockHorizontalPanNearFitValue, + blockedRects, + scale, + translateX, + translateY, + savedScale, + savedTranslateX, + savedTranslateY, + focalX, + focalY, + isGesturingRef, + isPinchActive, + lastPinchEventScale, + lastPanTranslationX, + lastPanTranslationY, + isDecayingX, + isDecayingY, + isClampingX, + isClampingY, + isMotionActive, + lastTransformNotificationTs, +}: UseZoomableViewportGesturesParams) => { + const notifyGestureStart = useCallback(() => { + onGestureStateChange?.(true); + }, [onGestureStateChange]); + + const notifyGestureEnd = useCallback(() => { + onGestureStateChange?.(false); + }, [onGestureStateChange]); + + const notifyMotionChange = useCallback((isMoving: boolean) => { + onMotionStateChange?.(isMoving); + }, [onMotionStateChange]); + + const notifyTransformChange = useCallback(( + nextScale: number, + nextTranslateX: number, + nextTranslateY: number, + nextContainerWidth: number, + nextContainerHeight: number + ) => { + onTransformChange?.({ + scale: nextScale, + translateX: nextTranslateX, + translateY: nextTranslateY, + containerWidth: nextContainerWidth, + containerHeight: nextContainerHeight, + }); + }, [onTransformChange]); + + const notifyContentTap = useCallback((locationX: number, locationY: number) => { + onContentTap?.({ + nativeEvent: { + locationX, + locationY, + isZoomableContentTap: true, + }, + }); + }, [onContentTap]); + + const isTouchBlocked = (x: number, y: number): boolean => { + "worklet"; + const currentScale = scale.value > 0 ? scale.value : 1; + const contentX = (x - translateX.value) / currentScale; + const contentY = (y - translateY.value) / currentScale; + + for (const rect of blockedRects.value) { + if ( + contentX >= rect.left && + contentX <= rect.right && + contentY >= rect.top && + contentY <= rect.bottom + ) { + return true; + } + } + + return false; + }; + + const shouldLockHorizontalTranslation = (currentScale: number): boolean => { + "worklet"; + return shouldLockHorizontalTranslationForLayout( + currentScale, + contentW.value, + containerWidth.value, + lockHorizontalPanNearFitValue.value, + isLandscapeMode.value, + ); + }; + + const stopMomentumAnimations = () => { + "worklet"; + cancelAnimation(scale); + cancelAnimation(translateX); + cancelAnimation(translateY); + isDecayingX.value = false; + isDecayingY.value = false; + isClampingX.value = false; + isClampingY.value = false; + savedScale.value = scale.value; + savedTranslateX.value = translateX.value; + savedTranslateY.value = translateY.value; + }; + + const syncMotionState = () => { + "worklet"; + const nextIsMoving = + isGesturingRef.value || + isDecayingX.value || + isDecayingY.value || + isClampingX.value || + isClampingY.value; + + if (isMotionActive.value === nextIsMoving) { + return; + } + + isMotionActive.value = nextIsMoving; + runOnJS(notifyMotionChange)(nextIsMoving); + }; + + useAnimatedReaction( + () => ({ + scale: scale.value, + translateX: translateX.value, + translateY: translateY.value, + containerWidth: containerWidth.value, + containerHeight: containerHeight.value, + }), + (nextTransform, previousTransform) => { + const containerChanged = + !previousTransform || + nextTransform.containerWidth !== previousTransform.containerWidth || + nextTransform.containerHeight !== previousTransform.containerHeight; + + if ( + previousTransform && + nextTransform.scale === previousTransform.scale && + nextTransform.translateX === previousTransform.translateX && + nextTransform.translateY === previousTransform.translateY && + !containerChanged + ) { + return; + } + + if (!containerChanged && transformNotificationMinIntervalMs > 0) { + const now = Date.now(); + if (now - lastTransformNotificationTs.value < transformNotificationMinIntervalMs) { + return; + } + lastTransformNotificationTs.value = now; + } else { + lastTransformNotificationTs.value = Date.now(); + } + + runOnJS(notifyTransformChange)( + nextTransform.scale, + nextTransform.translateX, + nextTransform.translateY, + nextTransform.containerWidth, + nextTransform.containerHeight + ); + } + ); + + const pinchGesture = useMemo(() => { + return Gesture.Pinch() + .enabled(enabled) + .onTouchesDown(() => { + "worklet"; + stopMomentumAnimations(); + syncMotionState(); + }) + .onStart((event) => { + "worklet"; + stopMomentumAnimations(); + // Don't claim "actively scaling" yet -- the gesture just + // started, scale ratio is 1.0. We only mark active in + // onUpdate when scale actually deviates between frames. + isPinchActive.value = false; + lastPinchEventScale.value = 1; + isGesturingRef.value = true; + runOnJS(notifyGestureStart)(); + syncMotionState(); + savedScale.value = scale.value; + savedTranslateX.value = translateX.value; + savedTranslateY.value = translateY.value; + focalX.value = event.focalX; + focalY.value = event.focalY; + }) + .onUpdate((event) => { + "worklet"; + // Per-frame scale delta. If fingers aren't actually + // changing distance this frame, the user is dragging -- + // unlock pan and skip translating from pinch. + const scaleDelta = Math.abs(event.scale - lastPinchEventScale.value); + if (scaleDelta < 0.0005) { + isPinchActive.value = false; + lastPinchEventScale.value = event.scale; + return; + } + + isPinchActive.value = true; + + // Per-frame scale ratio (relative to last frame, NOT to + // gesture start). Composes correctly with pan, which also + // updates per-frame deltas. + const frameRatio = event.scale / lastPinchEventScale.value; + const prevScale = scale.value; + const newScale = clamp(prevScale * frameRatio, minScale, maxScale); + const centerX = containerWidth.value / 2; + const centerY = containerHeight.value / 2; + + // Focal point zoom -- keep the content point currently under + // the fingers anchored to the same SCREEN position across + // this single frame. + // + // event.focalX/Y are reported in screen (GestureDetector + // view) coordinates from a stable, untransformed wrapper, so + // RNGH does NOT apply an inverse transform to the focal + // coords. Treating them as content coords gives wrong math + // whenever translateX/Y is non-zero (i.e. as soon as the + // user has scrolled or panned anywhere, which is common in + // continuous PDF mode). + // + // Derivation: + // screen = center + translate + scale * (content - center) + // focalContent = (focalScreen - center - prevTX) / prevScale + center + // For focalContent to stay at focalScreen after the zoom: + // focalScreen = center + newTX + newScale * (focalContent - center) + // Solve for newTX, simplify: + // newTX = prevTX + ((prevScale - newScale) / prevScale) + // * (focalScreen - center - prevTX) + // + // The previous formula was missing both the / prevScale + // ratio AND the - prevTX correction, which made every zoom + // jump the view (proportional to the current pan offset). + const dxFromCenter = event.focalX - centerX - translateX.value; + const dyFromCenter = event.focalY - centerY - translateY.value; + const scaleRatio = (prevScale - newScale) / prevScale; + let newTranslateX = translateX.value + scaleRatio * dxFromCenter; + let newTranslateY = translateY.value + scaleRatio * dyFromCenter; + + const clampedTranslation = clampTranslation( + newTranslateX, + newTranslateY, + newScale, + containerWidth.value, + containerHeight.value, + contentW.value, + contentH.value, + contentPad.value, + isLandscapeMode.value + ); + const isZoomingOut = newScale < prevScale; + newTranslateX = shouldLockHorizontalTranslation(newScale) && isZoomingOut + ? 0 + : clampedTranslation.x; + newTranslateY = clampedTranslation.y; + + scale.value = newScale; + // Clamp during pinch so zoom-out cannot expose empty space + // around the document. While zooming in, fitting content can + // still slide within the viewport so edge pinches stay + // anchored to the page edge instead of correcting inward. + translateX.value = newTranslateX; + translateY.value = newTranslateY; + + lastPinchEventScale.value = event.scale; + }) + .onEnd(() => { + "worklet"; + isPinchActive.value = false; + isGesturingRef.value = false; + runOnJS(notifyGestureEnd)(); + + // No release clamp animation: pinch updates already keep the + // transform inside document bounds, so an extra animation here + // would feel like a second, user-visible correction after the + // fingers lift. + + savedScale.value = scale.value; + savedTranslateX.value = translateX.value; + savedTranslateY.value = translateY.value; + + syncMotionState(); + + if (onZoomChange) { + runOnJS(onZoomChange)(scale.value); + } + }) + .onFinalize(() => { + "worklet"; + if (isGesturingRef.value) { + isGesturingRef.value = false; + runOnJS(notifyGestureEnd)(); + syncMotionState(); + } + }); + }, [enabled, minScale, maxScale, onZoomChange, notifyGestureStart, notifyGestureEnd, notifyMotionChange]); + + const panGesture = useMemo(() => { + const gesture = Gesture.Pan() + .enabled(enabled && panEnabled) + .minDistance(0) + .onTouchesDown((event, stateManager) => { + "worklet"; + stopMomentumAnimations(); + syncMotionState(); + + if (!fingerDrawingEnabled) { + // Reject stylus touches - let them reach the drawing canvas + // PointerType.STYLUS = 1 + if (event.pointerType === 1) { + stateManager.fail(); + return; + } + + // Reject touches in edge zones - let them reach page swipe handler + if (edgeExclusionWidth > 0) { + const touch = event.allTouches[0]; + if (touch && ( + touch.x < edgeExclusionWidth || + touch.x > containerWidth.value - edgeExclusionWidth + )) { + stateManager.fail(); + return; + } + } + + const touch = event.allTouches[0]; + if (touch && isTouchBlocked(touch.x, touch.y)) { + stateManager.fail(); + return; + } + + // Valid center-area finger touch - DO NOT activate yet. Defer activation + // until the user actually starts moving (handled in onTouchesMove below). + // If we activate here, a pure tap (touch + release with no movement) is + // claimed by this gesture and never propagates to the inner + // , which is what + // clears figure/text-box selection on tap-empty-canvas. The user-visible + // bug was "I can't tap to deselect, I have to use pencil." + } + }) + .onTouchesMove((_event, stateManager) => { + "worklet"; + // First movement after a valid touch-down promotes this pan to ACTIVE. + // (Stylus / edge / blocked touches were already failed in onTouchesDown, + // so we never reach here in those cases.) + if (!fingerDrawingEnabled) { + stateManager.activate(); + } + }); + + if (fingerDrawingEnabled) { + // Finger mode: only 2-finger pan (single finger is for drawing) + gesture.minPointers(2).maxPointers(2); + } else { + // Pencil mode: 1 or 2 finger pan (stylus is for drawing) + gesture + .minPointers(1) + .maxPointers(2) + .manualActivation(true); + } + + gesture + .onStart(() => { + "worklet"; + stopMomentumAnimations(); + isGesturingRef.value = true; + runOnJS(notifyGestureStart)(); + syncMotionState(); + savedTranslateX.value = translateX.value; + savedTranslateY.value = translateY.value; + lastPanTranslationX.value = 0; + lastPanTranslationY.value = 0; + }) + .onUpdate((event) => { + "worklet"; + // Yield to pinch only while it is *actively scaling* this + // frame. (isPinchActive is now per-frame -- it goes false + // during a pure 2-finger drag, so pan can run.) + if (isPinchActive.value) { + // Even though we're not applying translation, keep the + // per-frame baseline current so when pan resumes next + // frame we don't double-apply accumulated delta. + lastPanTranslationX.value = event.translationX; + lastPanTranslationY.value = event.translationY; + return; + } + + // Use per-frame delta (translationX since last frame) instead + // of cumulative. Cumulative would conflict with pinch's + // focal-point adjustments to translateX/Y on prior frames -- + // pan would snap the content back to (savedTranslate + + // cumulative) and undo the zoom translation. + const dx = event.translationX - lastPanTranslationX.value; + const dy = event.translationY - lastPanTranslationY.value; + lastPanTranslationX.value = event.translationX; + lastPanTranslationY.value = event.translationY; + const newTranslateX = translateX.value + dx; + const newTranslateY = translateY.value + dy; + + const bounds = getTranslationBounds( + scale.value, + containerWidth.value, + containerHeight.value, + contentW.value, + contentH.value, + contentPad.value, + isLandscapeMode.value + ); + + // If we're entering this frame already out of bounds (which + // happens after pinch-zoom near the top of the screen leaves + // translateY > maxY=0 in landscape continuous mode), snapping + // to bounds NOW would yank the content the user just zoomed + // into off-screen ("snaps down" in the user's words). Allow + // free movement back TOWARD bounds; only clamp the AXIS that's + // moving further out of bounds. + let nextX = newTranslateX; + let nextY = newTranslateY; + const xWasInBounds = + translateX.value >= bounds.minX && translateX.value <= bounds.maxX; + const yWasInBounds = + translateY.value >= bounds.minY && translateY.value <= bounds.maxY; + + if (shouldLockHorizontalTranslation(scale.value)) { + nextX = 0; + } else if (xWasInBounds) { + nextX = clamp(newTranslateX, bounds.minX, bounds.maxX); + } else if (translateX.value > bounds.maxX) { + // Currently overshooting maxX. Allow movement toward bounds + // (smaller X), block movement further past bounds. + nextX = Math.min(newTranslateX, translateX.value); + } else { + // Currently below minX. Allow movement up, block movement down. + nextX = Math.max(newTranslateX, translateX.value); + } + + if (yWasInBounds) { + nextY = clamp(newTranslateY, bounds.minY, bounds.maxY); + } else if (translateY.value > bounds.maxY) { + nextY = Math.min(newTranslateY, translateY.value); + } else { + nextY = Math.max(newTranslateY, translateY.value); + } + + translateX.value = nextX; + translateY.value = nextY; + }) + .onEnd((event) => { + "worklet"; + isGesturingRef.value = false; + runOnJS(notifyGestureEnd)(); + + if (enableMomentumScroll && !isPinchActive.value) { + const bounds = getTranslationBounds( + scale.value, + containerWidth.value, + containerHeight.value, + contentW.value, + contentH.value, + contentPad.value, + isLandscapeMode.value + ); + + // If translateY is currently out of bounds (e.g. because + // the user just finished a pinch zoom that anchored + // content above the maxY=0 ceiling in landscape continuous + // mode), withDecay's clamp would IMMEDIATELY snap Y to the + // nearest bound -- visible to the user as "the view jumps + // away when I let go." Skip decay in that case so the + // value stays where the pinch placed it; the user's next + // pan will gently bring it back via per-frame clamping. + const yIsOutOfBounds = + translateY.value > bounds.maxY || translateY.value < bounds.minY; + const xIsOutOfBounds = + translateX.value > bounds.maxX || translateX.value < bounds.minX; + + const shouldDecayX = + !shouldLockHorizontalTranslation(scale.value) && + !xIsOutOfBounds && + Math.abs(event.velocityX) > 20 && + bounds.minX !== bounds.maxX; + const shouldDecayY = + !yIsOutOfBounds && + Math.abs(event.velocityY) > 20 && + bounds.minY !== bounds.maxY; + + if (shouldDecayX) { + isDecayingX.value = true; + syncMotionState(); + translateX.value = withDecay({ + velocity: event.velocityX, + clamp: [bounds.minX, bounds.maxX], + }, () => { + "worklet"; + isDecayingX.value = false; + syncMotionState(); + }); + } + + if (shouldDecayY) { + isDecayingY.value = true; + syncMotionState(); + translateY.value = withDecay({ + velocity: event.velocityY, + clamp: [bounds.minY, bounds.maxY], + }, () => { + "worklet"; + isDecayingY.value = false; + syncMotionState(); + }); + } + } + + savedTranslateX.value = translateX.value; + savedTranslateY.value = translateY.value; + syncMotionState(); + }) + .onFinalize(() => { + "worklet"; + // Ensure we always notify end, even on cancel + if (isGesturingRef.value) { + isGesturingRef.value = false; + runOnJS(notifyGestureEnd)(); + syncMotionState(); + } + }); + + return gesture; + }, [enableMomentumScroll, enabled, fingerDrawingEnabled, edgeExclusionWidth, notifyGestureStart, notifyGestureEnd, notifyMotionChange, panEnabled]); + + const tapGesture = useMemo(() => { + return Gesture.Tap() + .enabled(enabled && !!onContentTap) + .maxDistance(10) + .maxDuration(350) + .onTouchesDown((event, stateManager) => { + "worklet"; + if (event.pointerType === 1) { + stateManager.fail(); + } + }) + .onEnd((event, success) => { + "worklet"; + if (!success) { + return; + } + + const currentScale = scale.value > 0 ? scale.value : 1; + const locationX = (event.x - translateX.value) / currentScale; + const locationY = (event.y - translateY.value) / currentScale; + runOnJS(notifyContentTap)(locationX, locationY); + }); + }, [enabled, notifyContentTap, onContentTap, scale, translateX, translateY]); + + return useMemo(() => { + return Gesture.Simultaneous(pinchGesture, panGesture, tapGesture); + }, [pinchGesture, panGesture, tapGesture]); +};