diff --git a/.github/workflows/canvas-audit.yml b/.github/workflows/canvas-audit.yml new file mode 100644 index 0000000..f07ce51 --- /dev/null +++ b/.github/workflows/canvas-audit.yml @@ -0,0 +1,111 @@ +name: Native Canvas Stall Audit + +on: + pull_request: + paths: + - 'ios/**' + - 'src/**' + - 'app/**' + - 'vendor/**' + - 'package*.json' + - 'scripts/ci/**' + - 'scripts/ios/**' + - '.github/workflows/canvas-audit.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + canvas-interaction-audit: + name: iPad canvas interaction stall audit + runs-on: macos-15 + timeout-minutes: 60 + env: + IOS_SIMULATOR_FAMILY: ipad + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --legacy-peer-deps + - run: node --test scripts/ci/verify-canvas-audit.test.mjs + - name: Select compatible native audit toolchain + run: echo "DEVELOPER_DIR=$(python3 scripts/ci/select-ios-toolchain.py)" >> "$GITHUB_ENV" + - name: Install iOS pods + run: pod install --project-directory=ios + - name: Resolve iPad simulator + run: echo "IOS_SIMULATOR_DESTINATION=$(node scripts/ci/resolve-ios-simulator.mjs)" >> "$GITHUB_ENV" + - name: Record toolchain and host conditions + run: | + xcodebuild -version + uname -m + uptime + xcrun simctl list runtimes + - name: Build native interaction tests + run: | + UITEST_FORCE_BUNDLING=1 xcodebuild build-for-testing \ + -workspace ios/OpenNotes.xcworkspace -scheme OpenNotesIntegrity \ + -derivedDataPath /tmp/opennotes-ui-deriveddata \ + -destination "$IOS_SIMULATOR_DESTINATION" -parallel-testing-enabled NO \ + ONLY_ACTIVE_ARCH=YES IPHONEOS_DEPLOYMENT_TARGET=15.1 COMPILER_INDEX_STORE_ENABLE=NO + - name: Seed owned 100-page lecture fixtures + run: | + simulator_id="${IOS_SIMULATOR_DESTINATION##*id=}" + python3 -c 'import subprocess,sys; subprocess.run(["xcrun", "simctl", "bootstatus", sys.argv[1], "-b"], check=True, timeout=180)' "$simulator_id" + xcrun simctl install "$simulator_id" /tmp/opennotes-ui-deriveddata/Build/Products/Release-iphonesimulator/OpenNotes.app + python3 scripts/seed-native-audit.py "$simulator_id" + - name: Run real native interaction workloads + id: interaction_workloads + run: | + xcodebuild test-without-building \ + -workspace ios/OpenNotes.xcworkspace -scheme OpenNotesIntegrity \ + -derivedDataPath /tmp/opennotes-ui-deriveddata \ + -destination "$IOS_SIMULATOR_DESTINATION" -parallel-testing-enabled NO \ + -only-testing:OpenNotesUITests/NotebookStallUITests/testDensePagesAndKeyboardInteractionAudit \ + -only-testing:OpenNotesUITests/NotebookStallUITests/testDensePanZoomInteractionAudit \ + -only-testing:OpenNotesUITests/NotebookStallUITests/testDenseWritingDuringAutosaveInteractionAudit \ + -only-testing:OpenNotesUITests/NotebookInteractionUITests/testStrokeCrossesPageBoundary \ + -only-testing:OpenNotesUITests/NotebookInteractionUITests/testCrossPageAssetHistory \ + -only-testing:OpenNotesUITests/NotebookInteractionUITests/testInvalidNativePayloadShowsRecoveryWithoutOverwritingNote \ + -collect-test-diagnostics never + - name: Verify persisted lecture content + run: | + simulator_id="${IOS_SIMULATOR_DESTINATION##*id=}" + python3 -c 'import subprocess,sys; subprocess.run(["xcrun", "simctl", "bootstatus", sys.argv[1], "-b"], check=True, timeout=180)' "$simulator_id" + python3 scripts/verify-native-audit.py "$simulator_id" + - name: Validate native responsiveness and render budgets + id: canvas_budgets + if: always() + run: | + python3 scripts/ci/collect-canvas-audits.py "$RUNNER_TEMP/canvas-audit-reports" + node scripts/ci/verify-canvas-audit.mjs "$RUNNER_TEMP/canvas-audit-reports" + - name: Profile interactions that missed the budgets + if: ${{ failure() && steps.interaction_workloads.outcome == 'success' && steps.canvas_budgets.outcome == 'failure' }} + run: | + python3 scripts/ci/sample-canvas-process.py OpenNotes "$RUNNER_TEMP/canvas-audit-profiles" & + profile_pid=$! + trap 'kill "$profile_pid" 2>/dev/null || true; wait "$profile_pid" || true' EXIT + xcodebuild test-without-building \ + -workspace ios/OpenNotes.xcworkspace -scheme OpenNotesIntegrity \ + -derivedDataPath /tmp/opennotes-ui-deriveddata \ + -destination "$IOS_SIMULATOR_DESTINATION" -parallel-testing-enabled NO \ + -only-testing:OpenNotesUITests/NotebookStallUITests/testDensePagesAndKeyboardInteractionAudit \ + -only-testing:OpenNotesUITests/NotebookStallUITests/testDensePanZoomInteractionAudit \ + -only-testing:OpenNotesUITests/NotebookStallUITests/testDenseWritingDuringAutosaveInteractionAudit \ + -collect-test-diagnostics never + - name: Preserve audit evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: canvas-interaction-audit + path: | + /tmp/opennotes-ui-deriveddata/Logs/Test + ${{ runner.temp }}/canvas-audit-reports + ${{ runner.temp }}/canvas-audit-profiles + if-no-files-found: warn + retention-days: 7 diff --git a/app/note/[id].tsx b/app/note/[id].tsx index de495e3..6825468 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -1,3 +1,5 @@ +import {selectedOverlayTouchRects} from '../../src/utils/overlayTouchRects'; +import {useOverlayHistory} from '../../src/components/editor/history/useOverlayHistory'; import React, { useCallback, useEffect, @@ -23,8 +25,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { - InfiniteInkCanvas, - type InfiniteInkCanvasRef, + ContinuousDocumentCanvas as InfiniteInkCanvas, + type ContinuousDocumentCanvasRef, type InkTextBox, type InsertedElement, type NotebookPage, @@ -61,6 +63,7 @@ import { } from '../../src/services/imageInsertStorage'; import { exportNotebookAsPdf } from '../../src/services/exportService'; import { recordSuccessfulNoteSave } from '../../src/services/lifecycleService'; +import { duplicateAssetPosition } from '../../src/utils/continuousAssets'; import { textBoxId, insertedElementId } from '../../src/utils/id'; import { spacing } from '../../src/theme/spacing'; import { t } from '../../src/i18n'; @@ -68,6 +71,7 @@ import { typography } from '../../src/theme/typography'; import type { NoteMetadata } from '../../src/types/note'; import type { ToolDescriptor } from '../../src/utils/toolPalette'; +const CONTENT_PADDING = 16; const PAGE_WIDTH = 820; const PAGE_HEIGHT = 1061; const FINGER_DRAWING_PREF_KEY = 'opennotes.editor.fingerDrawingEnabled'; @@ -135,7 +139,7 @@ export default function NoteScreen() { const { width: viewportWidth, height: viewportHeight } = useWindowDimensions(); const { id } = useLocalSearchParams<{ id: string }>(); - const canvasRef = useRef(null); + const canvasRef = useRef(null); const pendingBodyRef = useRef(null); const canvasReadyRef = useRef(false); const isMountedRef = useRef(true); @@ -148,13 +152,20 @@ export default function NoteScreen() { const [metadata, setMetadata] = useState(null); const [enginePages, setEnginePages] = useState([]); + const enginePagesRef = useRef(enginePages); + const updateEnginePages = useCallback((pages: NotebookPage[]) => { + enginePagesRef.current = pages; + setEnginePages(pages); + }, []); + const flushHistoryRef = useRef<() => Promise>(async () => {}); const [overlayMap, setOverlayMap] = useState>( () => new Map(), ); const overlayMapRef = useRef(overlayMap); - useEffect(() => { - overlayMapRef.current = overlayMap; - }, [overlayMap]); + const replaceOverlayMap = useCallback((next: Map) => { + overlayMapRef.current = next; + setOverlayMap(next); + }, []); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [autosaveStatus, setAutosaveStatus] = useState('idle'); @@ -164,6 +175,7 @@ export default function NoteScreen() { // Autosave stays disabled until the body has loaded (or the user explicitly // chose to start blank), so nothing can overwrite an unread body on disk. const [bodyReady, setBodyReady] = useState(false); + const [nativeContentReady, setNativeContentReady] = useState(false); const [selection, setSelection] = useState(null); const [action, setAction] = useState(null); const [isExporting, setIsExporting] = useState(false); @@ -203,6 +215,7 @@ export default function NoteScreen() { const persistMerged = useCallback(async () => { if (!id || !canvasRef.current) return; + await flushHistoryRef.current(); const canvasData = await canvasRef.current.getNotebookData(); const overlay = overlayMapRef.current; const mergedPages = mergeStoredPreviews(canvasData.pages).map((page) => { @@ -237,6 +250,13 @@ export default function NoteScreen() { enabled: Boolean(id) && autosaveEnabled && bodyReady, }); + const history = useOverlayHistory({fileId: id, canvasRef, + readPages: () => enginePagesRef.current.map(page => ({...page, ...overlayMapRef.current.get(page.id)})), + writePages: pages => replaceOverlayMap(new Map(pages.map(page => [page.id, overlayFromPage(page)]))), + onChange: scheduleAutosave, + }); + flushHistoryRef.current = history.flushOverlayHistory; + useEffect(() => { isMountedRef.current = true; return () => { @@ -276,23 +296,36 @@ export default function NoteScreen() { }); }, [fingerDrawingEnabled]); + const handleCanvasLoadError = useCallback((error: unknown) => { + if (__DEV__) console.warn('[NoteScreen] native notebook load failed', error); + if (!isMountedRef.current) return; + canvasReadyRef.current = false; + setNativeContentReady(false); + pendingBodyRef.current = null; + setBodyReady(false); + setBodyLoadFailed(true); + }, []); + const applyNotebookData = useCallback( (notebookData: SerializedNotebookData) => { + setNativeContentReady(false); rememberPagePreviews(notebookData.pages); - setEnginePages(mergeStoredPreviews(notebookData.pages)); + updateEnginePages(mergeStoredPreviews(notebookData.pages)); const initialMap = new Map(); for (const page of notebookData.pages) { initialMap.set(page.id, overlayFromPage(page)); } - setOverlayMap(initialMap); + replaceOverlayMap(initialMap); setBodyReady(true); - if (canvasReadyRef.current) { - void canvasRef.current?.loadNotebookData(notebookData); + if (canvasReadyRef.current && canvasRef.current) { + void canvasRef.current.loadNotebookData(notebookData).then(() => { + if (isMountedRef.current) setNativeContentReady(true); + }).catch(handleCanvasLoadError); } else { pendingBodyRef.current = notebookData; } }, - [mergeStoredPreviews, rememberPagePreviews], + [mergeStoredPreviews, rememberPagePreviews, handleCanvasLoadError], ); // Initial load @@ -302,7 +335,7 @@ export default function NoteScreen() { storedPreviewByPageIdRef.current = new Map(); setBodyReady(false); setBodyLoadFailed(false); - setEnginePages([]); + updateEnginePages([]); setCurrentPageIndex(0); setLoading(true); (async () => { @@ -357,17 +390,22 @@ export default function NoteScreen() { const pending = pendingBodyRef.current; if (pending) { pendingBodyRef.current = null; - void canvasRef.current?.loadNotebookData(pending); + void canvasRef.current?.loadNotebookData(pending).then(() => { + if (isMountedRef.current) setNativeContentReady(true); + }).catch(handleCanvasLoadError); + } else { + setNativeContentReady(true); } - }, []); + }, [handleCanvasLoadError]); const handlePagesChange = useCallback((next: NotebookPage[]) => { if (!isMountedRef.current || exitRef.current === 'navigating') return; rememberPagePreviews(next); const pagesWithPreviews = mergeStoredPreviews(next); - setEnginePages(pagesWithPreviews); + updateEnginePages(pagesWithPreviews); // Make sure every page id has an overlay slot; drop overlays for removed pages. - setOverlayMap((prev) => { + { + const prev = overlayMapRef.current; let changed = false; const nextMap = new Map(prev); const idsInPages = new Set(pagesWithPreviews.map((p) => p.id)); @@ -383,8 +421,8 @@ export default function NoteScreen() { changed = true; } } - return changed ? nextMap : prev; - }); + if (changed) replaceOverlayMap(nextMap); + } }, [mergeStoredPreviews, rememberPagePreviews]); const handleDrawingChange = useCallback(() => { @@ -459,13 +497,13 @@ export default function NoteScreen() { ); const handleUndo = useCallback(() => { - canvasRef.current?.undo(); + history.undoHistory(); scheduleAutosave(); - }, [scheduleAutosave]); + }, [scheduleAutosave, history.undoHistory]); const handleRedo = useCallback(() => { - canvasRef.current?.redo(); + history.redoHistory(); scheduleAutosave(); - }, [scheduleAutosave]); + }, [scheduleAutosave, history.redoHistory]); const handleJumpToPage = useCallback( (pageIndex: number) => { @@ -496,18 +534,13 @@ export default function NoteScreen() { pageIndex: number, updater: (state: PageOverlayState) => PageOverlayState, ) => { - setOverlayMap((prev) => { - const page = enginePages[pageIndex]; - if (!page) return prev; - const current = prev.get(page.id) ?? emptyOverlay(); - const next = updater(current); - const nextMap = new Map(prev); - nextMap.set(page.id, next); - return nextMap; - }); + const pageId = enginePagesRef.current[pageIndex]?.id; + if (!pageId) return; + history.setOverlayPages(pages => pages.map(page => page.id === pageId + ? {...page, ...updater(overlayFromPage(page))} : page)); scheduleAutosave(); }, - [enginePages, scheduleAutosave], + [history.setOverlayPages, scheduleAutosave], ); const applyInsertedImage = useCallback( @@ -644,12 +677,15 @@ export default function NoteScreen() { const now = new Date().toISOString(); const nextId = insertedElementId(); - const maxZIndex = Math.max(0, ...state.insertedElements.map((el) => el.zIndex ?? 0)); + const maxZIndex = Array.from(overlayMapRef.current.values()).reduce( + (highest, overlay) => overlay.insertedElements.reduce( + (value, asset) => Math.max(value, asset.zIndex ?? 0), highest, + ), 0, + ); const duplicated: InsertedElement = { ...element, id: nextId, - x: Math.max(0, Math.min(PAGE_WIDTH - (element.width ?? 80), element.x + 20)), - y: Math.max(0, Math.min(PAGE_HEIGHT - (element.height ?? 80), element.y + 20)), + ...duplicateAssetPosition(element, pageIndex, PAGE_WIDTH, PAGE_HEIGHT, enginePages.length), zIndex: maxZIndex + 1, createdAt: now, updatedAt: now, @@ -696,6 +732,7 @@ export default function NoteScreen() { setIsExporting(true); try { await flushNow(); + await flushHistoryRef.current(); const canvasData = await canvasRef.current.getNotebookData(); const overlay = overlayMapRef.current; const mergedPages = mergeStoredPreviews(canvasData.pages).map((page) => { @@ -814,6 +851,11 @@ export default function NoteScreen() { }); }, [enginePages, overlayMap]); + const overlayTouchRects = useMemo(() => selectedOverlayTouchRects(pagesForOverlay, selection, + {pageWidth: PAGE_WIDTH, pageHeight: PAGE_HEIGHT, contentPadding: CONTENT_PADDING, + containerWidth: store.getSnapshot()?.containerWidth ?? viewportWidth}), + [pagesForOverlay, selection, store, viewportWidth]); + if (!id) { return ( @@ -901,6 +943,10 @@ export default function NoteScreen() { ref={canvasRef} style={styles.flex} initialPageCount={1} + minScale={0.25} + renderBackend={Platform.OS === "ios" ? "ganesh" : "cpu"} + contentPadding={CONTENT_PADDING} + blockedTouchRects={overlayTouchRects} pageWidth={PAGE_WIDTH} pageHeight={PAGE_HEIGHT} toolState={toolState} @@ -908,7 +954,10 @@ export default function NoteScreen() { pdfBackgroundBaseUri={metadata?.pdfUri ?? undefined} fingerDrawingEnabled={fingerDrawingEnabled} onReady={handleCanvasReady} + onError={handleCanvasLoadError} onDrawingChange={handleDrawingChange} + onExternalEdit={payload => history.handleHistoryDrawingChange({nativeEvent: {externalEdit: {payload}}})} + onHistoryAction={historyAction => history.handleHistoryDrawingChange({nativeEvent: {historyAction}})} onCurrentPageChange={handleCurrentPageChange} onPagesChange={handlePagesChange} onPencilDoubleTap={handlePencilDoubleTap} @@ -936,6 +985,7 @@ export default function NoteScreen() { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/OpenNotes/AppDelegate.swift b/ios/OpenNotes/AppDelegate.swift index d338f71..0b4d9aa 100644 --- a/ios/OpenNotes/AppDelegate.swift +++ b/ios/OpenNotes/AppDelegate.swift @@ -1,10 +1,12 @@ import Expo +import Metal import React import ReactAppDependencyProvider @UIApplicationMain public class AppDelegate: ExpoAppDelegate { var window: UIWindow? + private var inkMetalDevice: MTLDevice? var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory? @@ -13,6 +15,12 @@ public class AppDelegate: ExpoAppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // First Metal-device initialization was measured blocking notebook opening. + // Prepare it while React Native starts, and retain it for subsequent canvases. + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let device = MTLCreateSystemDefaultDevice() + DispatchQueue.main.async { self?.inkMetalDevice = device } + } let delegate = ReactNativeDelegate() let factory = ExpoReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider() diff --git a/ios/OpenNotesUITests/InkVisibilityAssertions.swift b/ios/OpenNotesUITests/InkVisibilityAssertions.swift new file mode 100644 index 0000000..c1ec9e1 --- /dev/null +++ b/ios/OpenNotesUITests/InkVisibilityAssertions.swift @@ -0,0 +1,72 @@ +import XCTest +import UIKit + +/// The dense fixture uses dark ink on a light page. Sample only page content, +/// excluding the navigation/toolbars, page edges, and floating AI control. +private func visibleInkSamples(_ image: UIImage) -> Int { + guard let source = image.cgImage else { return 0 } + let width = source.width, height = source.height + var pixels = [UInt8](repeating: 255, count: width * height * 4) + return pixels.withUnsafeMutableBytes { bytes in + guard let context = CGContext(data: bytes.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) + else { return 0 } + context.draw(source, in: CGRect(x: 0, y: 0, width: width, height: height)) + let data = bytes.bindMemory(to: UInt8.self) + var count = 0 + for y in (height / 5)..<(height * 2 / 3) { + for x in (width / 100)..<(width * 85 / 100) { + let offset = (y * width + x) * 4 + if data[offset] < 90 && data[offset + 1] < 90 && data[offset + 2] < 110 { count += 1 } + } + } + return count + } +} + +func assertDenseInkVisible(_ app: XCUIApplication, context: String, + file: StaticString = #filePath, line: UInt = #line) { + let predicate = NSPredicate { _, _ in visibleInkSamples(app.screenshot().image) > 100 } + let result = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: predicate, object: nil)], timeout: 8) + if result != .completed { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Missing dense ink — \(context)" + attachment.lifetime = .keepAlways + XCTContext.runActivity(named: context) { $0.add(attachment) } + } + XCTAssertEqual(result, .completed, "Expected visible dense ink: \(context)", file: file, line: line) +} + +/// Count newly darkened pixels in a screen-coordinate rectangle, excluding +/// preexisting fixture ink. Both captures use the same transform and viewport. +func changedInkPixels(_ before: UIImage, _ after: UIImage, rect: CGRect) -> Int { + guard let first = before.cgImage, let second = after.cgImage, + first.width == second.width, first.height == second.height else { return 0 } + let width = first.width, height = first.height + func pixels(_ image: CGImage) -> [UInt8] { + var data = [UInt8](repeating: 255, count: width * height * 4) + data.withUnsafeMutableBytes { bytes in + let context = CGContext(data: bytes.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)! + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return data + } + let a = pixels(first), b = pixels(second) + let scale = CGFloat(width) / after.size.width + let area = rect.applying(CGAffineTransform(scaleX: scale, y: scale)) + .intersection(CGRect(x: 0, y: 0, width: width, height: height)) + guard !area.isNull else { return 0 } + var changed = 0 + for y in Int(area.minY).. 100 { + changed += 1 + } + } + } + return changed +} diff --git a/ios/OpenNotesUITests/NativeCanvasAudit.swift b/ios/OpenNotesUITests/NativeCanvasAudit.swift new file mode 100644 index 0000000..61092ea --- /dev/null +++ b/ios/OpenNotesUITests/NativeCanvasAudit.swift @@ -0,0 +1,76 @@ +import XCTest +import notify +import ObjectiveC.runtime + +// Query geometry outside timing windows. Gestures are anchored to the app's +// root frame so XCTest does not traverse the full accessibility tree mid-audit. +enum NativeCanvasAudit { + // Expo Blur keeps a paused UIKit property animator alive. XCTest otherwise + // waits 60 seconds around each gesture even while the app's main loop is idle. + // Change only the runner's animation-idle wait; retain its run-loop wait and + // all product animations. Native heartbeat/render budgets remain unchanged. + static func useRunLoopQuiescence() { + XCTAssertTrue(runLoopQuiescenceInstalled, "Unsupported XCTest quiescence API") + } + + private static let runLoopQuiescenceInstalled: Bool = { + guard let processClass = NSClassFromString("XCUIApplicationProcess") else { return false } + let modern = NSSelectorFromString("waitForQuiescenceIncludingAnimationsIdle:isPreEvent:") + if let method = class_getInstanceMethod(processClass, modern) { + typealias Original = @convention(c) (AnyObject, Selector, Bool, Bool) -> Void + let original = unsafeBitCast(method_getImplementation(method), to: Original.self) + let replacement: @convention(block) (AnyObject, Bool, Bool) -> Void = { process, _, preEvent in + original(process, modern, false, preEvent) + } + method_setImplementation(method, imp_implementationWithBlock(replacement)) + return true + } + let legacy = NSSelectorFromString("waitForQuiescenceIncludingAnimationsIdle:") + guard let method = class_getInstanceMethod(processClass, legacy) else { return false } + typealias Original = @convention(c) (AnyObject, Selector, Bool) -> Void + let original = unsafeBitCast(method_getImplementation(method), to: Original.self) + let replacement: @convention(block) (AnyObject, Bool) -> Void = { process, _ in + original(process, legacy, false) + } + method_setImplementation(method, imp_implementationWithBlock(replacement)) + return true + }() + + static func startup(bundle: String, open: () -> Void) { + acknowledge(bundle: bundle, action: "startup", timeout: 30, trigger: open) + } + + static func window(bundle: String, action: () -> Void) { + acknowledge(bundle: bundle, action: "begin") { + XCTAssertEqual(notify_post(name(bundle, "begin")), 0) + } + action() + Thread.sleep(forTimeInterval: 0.65) + acknowledge(bundle: bundle, action: "end") { + XCTAssertEqual(notify_post(name(bundle, "end")), 0) + } + } + + static func point(_ app: XCUIApplication, _ point: CGPoint) -> XCUICoordinate { + app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + .withOffset(CGVector(dx: point.x, dy: point.y)) + } + + private static func name(_ bundle: String, _ action: String) -> String { + "com.mathnotes.mobile-ink.audit.\(bundle).\(action)" + } + + private static func acknowledge(bundle: String, action: String, timeout: Double = 5, + trigger: () -> Void) { + let received = DispatchSemaphore(value: 0) + var token: Int32 = 0 + let status = notify_register_dispatch(name(bundle, action) + ".ack", &token, .global(qos: .userInteractive)) { _ in + received.signal() + } + guard status == 0 else { XCTFail("Could not observe native audit acknowledgement"); return } + defer { notify_cancel(token) } + trigger() + XCTAssertEqual(received.wait(timeout: .now() + timeout), .success, + "Native audit did not acknowledge \(action)") + } +} diff --git a/ios/OpenNotesUITests/NotebookInteractionUITests.swift b/ios/OpenNotesUITests/NotebookInteractionUITests.swift new file mode 100644 index 0000000..c78b05c --- /dev/null +++ b/ios/OpenNotesUITests/NotebookInteractionUITests.swift @@ -0,0 +1,423 @@ +import XCTest +import UIKit + +final class NotebookInteractionUITests: NotebookUITestCase { + func testInvalidNativePayloadShowsRecoveryWithoutOverwritingNote() { + let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + launchPrepared(app) + tap(app, "note-audit-invalid-native-payload") + XCTAssertTrue(app.staticTexts["Couldn't open this note"].waitForExistence(timeout: 20)) + tap(app, "Try again") + XCTAssertTrue(app.staticTexts["Couldn't open this note"].waitForExistence(timeout: 20)) + attach(app, "Native payload failure recovery") + tap(app, "Back to library") + XCTAssertTrue(element(app, "note-audit-invalid-native-payload").waitForExistence(timeout: 15)) + app.terminate() + launchPrepared(app) + tap(app, "note-audit-invalid-native-payload") + XCTAssertTrue(app.staticTexts["Couldn't open this note"].waitForExistence(timeout: 20)) + } + + func testDenseInteractionAudit() { + let app = prepare() + for tool in ["eraser", "pen", "highlighter", "pen"] { + tap(app, "tool-\(tool)") + XCTAssertTrue(element(app, "tool-\(tool)").isSelected) + } + tap(app, "Pages") + tap(app, "Close pages") + let viewport = element(app, "ink-viewport") + viewport.pinch(withScale: 2, velocity: 1) + assertDenseInkVisible(app, context: "OpenNotes 2x zoom") + attach(app, "OpenNotes at 2x") + viewport.pinch(withScale: 2.5, velocity: 1) + assertDenseInkVisible(app, context: "OpenNotes 5x zoom") + attach(app, "OpenNotes at 5x") + viewport.pinch(withScale: 0.2, velocity: -1) + for _ in 0..<6 { viewport.swipeUp() } + for _ in 0..<6 { viewport.swipeDown() } + assertDenseInkVisible(app, context: "OpenNotes after long scrolling") + attach(app, "OpenNotes after scrolling") + } + + func testStrokeCrossesPageBoundary() { + let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + launchPrepared(app) + let communityDismiss = app.buttons["Not now"] + if communityDismiss.waitForExistence(timeout: 2) { communityDismiss.tap() } + tap(app, "note-audit-cross-page") + let viewport = element(app, "ink-viewport") + XCTAssertTrue(viewport.waitForExistence(timeout: 20)) + let disable = element(app, "Disable finger drawing") + if disable.exists { disable.tap() } + let start = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + let end = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)) + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1) + let boundary = element(app, "ink-page-boundary-1") + XCTAssertTrue(boundary.waitForExistence(timeout: 10)) + let y = boundary.frame.midY + XCTAssertGreaterThan(y, viewport.frame.minY + 100) + XCTAssertLessThan(y, viewport.frame.maxY - 100) + tap(app, "Enable finger drawing") + tap(app, "tool-highlighter") + tap(app, "tool-pen") + let x = viewport.frame.midX + let before = app.screenshot().image + let origin = app.coordinate(withNormalizedOffset: .zero) + origin.withOffset(CGVector(dx: x, dy: y - 80)).press(forDuration: 0.05, + thenDragTo: origin.withOffset(CGVector(dx: x, dy: y + 80)), + withVelocity: .slow, thenHoldForDuration: 0.1) + let after = app.screenshot().image + attach(app, "Stroke crossing first page boundary") + XCTAssertGreaterThan(changedInkPixels(before, after, + rect: CGRect(x: x - 8, y: y - 65, width: 16, height: 40)), 20, + "The stroke must appear before the page boundary") + XCTAssertGreaterThan(changedInkPixels(before, after, + rect: CGRect(x: x - 8, y: y + 25, width: 16, height: 40)), 20, + "The same stroke must continue visibly onto the next page") + tap(app, "Undo") + let undone = app.screenshot().image + XCTAssertLessThan(changedInkPixels(before, undone, + rect: CGRect(x: x - 8, y: y - 65, width: 16, height: 130)), 20, + "One undo must remove both halves of the crossing stroke") + tap(app, "Redo") + let redone = app.screenshot().image + XCTAssertGreaterThan(changedInkPixels(before, redone, + rect: CGRect(x: x - 8, y: y + 25, width: 16, height: 40)), 20, + "Redo must restore the lower half as part of the same stroke") + tap(app, "editor-back") + XCTAssertTrue(element(app, "note-audit-cross-page").waitForExistence(timeout: 15)) + app.terminate() + launchPrepared(app) + tap(app, "note-audit-cross-page") + XCTAssertTrue(viewport.waitForExistence(timeout: 20)) + let disableAfterReload = element(app, "Disable finger drawing") + if disableAfterReload.exists { disableAfterReload.tap() } + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1) + XCTAssertTrue(boundary.waitForExistence(timeout: 10)) + let reopenedY = boundary.frame.midY + let reopened = app.screenshot().image + // The lower page fixture is blank. Compare the persisted lower stroke + // against a white reference generated at the same screenshot dimensions. + let white = UIGraphicsImageRenderer(size: reopened.size).image { context in + UIColor.white.setFill() + context.fill(CGRect(origin: .zero, size: reopened.size)) + } + XCTAssertGreaterThan(changedInkPixels(white, reopened, + rect: CGRect(x: x - 8, y: reopenedY + 25, width: 16, height: 40)), 20, + "Saved crossing stroke must survive full application relaunch") + attach(app, "Cross-page stroke after undo, redo, save and relaunch") + tap(app, "Export") + let copy = element(app, "Copy") + XCTAssertTrue(copy.waitForExistence(timeout: 30), "Expected the generated PDF share sheet") + attach(app, "Cross-page PDF export share sheet") + } + + func testCrossPageAssetHistory() { + verifyCrossPageOverlay(noteID: "note-audit-cross-asset", overlayID: "inserted-image-cross-image", + resizeID: "image-resize-cross-image-bottom-right", initialWidth: 180) + } + + func testRotatedImageResizeAndCrop() { + let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + launchPrepared(app) + let dismiss = app.buttons["Not now"] + if dismiss.waitForExistence(timeout: 2) { dismiss.tap() } + tap(app, "note-audit-rotated-asset") + let viewport = element(app, "ink-viewport") + XCTAssertTrue(viewport.waitForExistence(timeout: 20)) + let disable = element(app, "Disable finger drawing") + if disable.exists { disable.tap() } + viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.8)) + .press(forDuration: 0.1, thenDragTo: viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.4)), withVelocity: .slow, thenHoldForDuration: 0.5) + let asset = element(app, "inserted-image-cross-image") + XCTAssertTrue(asset.waitForExistence(timeout: 10)) + viewport.pinch(withScale: 2, velocity: 1) + for _ in 0..<5 { + if asset.frame.minY > viewport.frame.minY + 100 && asset.frame.maxY < viewport.frame.maxY - 100 { break } + let delta = max(-250, min(250, viewport.frame.midY - asset.frame.midY)) + let margin = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)) + margin.press(forDuration: 0.1, thenDragTo: margin.withOffset(CGVector(dx: 0, dy: delta)), withVelocity: .slow, thenHoldForDuration: 0.5) + } + let zoom = asset.frame.width / 140 // Width/height swap at 90 degrees. + XCTAssertGreaterThan(zoom, 1.8); XCTAssertLessThan(zoom, 2.2) + asset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + let before = asset.frame + let handle = element(app, "image-resize-cross-image-bottom-right") + XCTAssertTrue(handle.waitForExistence(timeout: 5)) + let corner = handle.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: -40, dy: 50)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.width - before.width, 40, accuracy: 6) + XCTAssertEqual(asset.frame.height - before.height, 50, accuracy: 6) + XCTAssertEqual(asset.frame.maxX, before.maxX, accuracy: 3) + XCTAssertEqual(asset.frame.minY, before.minY, accuracy: 3) + tap(app, "Undo") + XCTAssertEqual(asset.frame.width, before.width, accuracy: 3) + XCTAssertEqual(asset.frame.minY, before.minY, accuracy: 3) + tap(app, "Redo") + let resized = asset.frame + tap(app, "image-crop-toggle-cross-image") + let crop = element(app, "image-crop-cross-image-left") + XCTAssertTrue(crop.waitForExistence(timeout: 5)) + let edge = crop.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + edge.press(forDuration: 0.1, thenDragTo: edge.withOffset(CGVector(dx: 0, dy: 40)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.height, resized.height - 40, accuracy: 6) + XCTAssertEqual(asset.frame.minY, resized.minY + 40, accuracy: 6) + XCTAssertEqual(asset.frame.maxY, resized.maxY, accuracy: 3) + XCTAssertEqual(asset.frame.maxX, resized.maxX, accuracy: 3) + tap(app, "Undo") + XCTAssertEqual(asset.frame.height, resized.height, accuracy: 3) + tap(app, "Redo") + tap(app, "image-crop-reset-cross-image") + XCTAssertEqual(asset.frame.width, resized.width, accuracy: 3) + XCTAssertEqual(asset.frame.height, resized.height, accuracy: 3) + XCTAssertEqual(asset.frame.minY, resized.minY, accuracy: 3) + tap(app, "Undo") // Restore the cropped state for persistence/export. + let lockedFrame = asset.frame + tap(app, "image-lock-cross-image") + XCTAssertFalse(element(app, "image-crop-toggle-cross-image").exists) + XCTAssertFalse(element(app, "image-resize-cross-image-bottom-right").exists) + let lockedCenter = asset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + lockedCenter.press(forDuration: 0.1, thenDragTo: lockedCenter.withOffset(CGVector(dx: 25, dy: 30)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.minX, lockedFrame.minX, accuracy: 3) + XCTAssertEqual(asset.frame.minY, lockedFrame.minY, accuracy: 3) + tap(app, "image-lock-cross-image") + let savedWidth = asset.frame.width / zoom, savedHeight = asset.frame.height / zoom + let savedTop = (asset.frame.minY - element(app, "ink-page-boundary-1").frame.midY) / zoom + XCTAssertLessThan(savedTop, 0); XCTAssertGreaterThan(savedTop + savedHeight, 0) + attach(app, "Rotated crop keeps opposite edge at 2x") + tap(app, "Library") + XCTAssertTrue(element(app, "note-audit-rotated-asset").waitForExistence(timeout: 15)) + app.terminate(); app.launch() + tap(app, "note-audit-rotated-asset") + XCTAssertTrue(viewport.waitForExistence(timeout: 20)) + let finger = element(app, "Disable finger drawing") + if finger.exists { finger.tap() } + viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.8)) + .press(forDuration: 0.1, thenDragTo: viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.4)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertTrue(asset.waitForExistence(timeout: 10)) + XCTAssertEqual(asset.frame.width, savedWidth, accuracy: 3) + XCTAssertEqual(asset.frame.height, savedHeight, accuracy: 3) + XCTAssertEqual(asset.frame.minY - element(app, "ink-page-boundary-1").frame.midY, savedTop, accuracy: 3) + attach(app, "Rotated crop restored after restart") + tap(app, "Export") + XCTAssertTrue(element(app, "Copy").waitForExistence(timeout: 30)) + } + + func testCrossPageTextHistory() { + verifyCrossPageOverlay(noteID: "note-audit-cross-text", overlayID: "text-box-cross-text", + resizeID: "text-box-resize-bottom-right", initialWidth: 240) + } + + private func verifyCrossPageOverlay(noteID: String, overlayID: String, resizeID: String, initialWidth: CGFloat) { + let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + launchPrepared(app) + let dismiss = app.buttons["Not now"] + if dismiss.waitForExistence(timeout: 2) { dismiss.tap() } + tap(app, noteID) + let viewport = element(app, "ink-viewport") + XCTAssertTrue(viewport.waitForExistence(timeout: 20)) + let disable = element(app, "Disable finger drawing") + if disable.exists { disable.tap() } + viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.8)) + .press(forDuration: 0.1, thenDragTo: viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.4)), withVelocity: .slow, thenHoldForDuration: 0.5) + let asset = element(app, overlayID) + XCTAssertTrue(asset.waitForExistence(timeout: 10)) + let boundary = element(app, "ink-page-boundary-1").frame.midY + XCTAssertLessThan(asset.frame.minY, boundary) + XCTAssertGreaterThan(asset.frame.maxY, boundary) + let start = asset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.25)) + start.tap() + let originalY = asset.frame.midY + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: 90)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.midY - originalY, 90, accuracy: 6) + XCTAssertEqual(element(app, "ink-page-boundary-1").frame.midY, boundary, accuracy: 2, + "Dragging a selected overlay must not pan the viewport") + tap(app, "Undo") + XCTAssertEqual(asset.frame.midY, originalY, accuracy: 2) + tap(app, "Redo") + XCTAssertEqual(asset.frame.midY - originalY, 90, accuracy: 6) + // Ink and overlay edits must share one chronological undo stack. + tap(app, "Enable finger drawing") + tap(app, "tool-highlighter") + tap(app, "tool-pen") + let inkX = viewport.frame.midX - 180 + let inkY = viewport.frame.midY + let inkRect = CGRect(x: inkX - 8, y: inkY - 35, width: 16, height: 70) + let beforeInk = app.screenshot().image + let origin = app.coordinate(withNormalizedOffset: .zero) + origin.withOffset(CGVector(dx: inkX, dy: inkY - 40)).press(forDuration: 0.05, + thenDragTo: origin.withOffset(CGVector(dx: inkX, dy: inkY + 40)), withVelocity: .slow, thenHoldForDuration: 0.1) + XCTAssertGreaterThan(changedInkPixels(beforeInk, app.screenshot().image, rect: inkRect), 20) + tap(app, "Undo") + XCTAssertLessThan(changedInkPixels(beforeInk, app.screenshot().image, rect: inkRect), 20) + XCTAssertEqual(asset.frame.midY - originalY, 90, accuracy: 6) + tap(app, "Undo") + XCTAssertEqual(asset.frame.midY, originalY, accuracy: 2) + tap(app, "Redo") + XCTAssertEqual(asset.frame.midY - originalY, 90, accuracy: 6) + tap(app, "Redo") + XCTAssertGreaterThan(changedInkPixels(beforeInk, app.screenshot().image, rect: inkRect), 20) + tap(app, "Disable finger drawing") + asset.tap() + viewport.pinch(withScale: 2, velocity: 1) + for _ in 0..<5 { + if asset.frame.minY > viewport.frame.minY + 80 && asset.frame.maxY < viewport.frame.maxY - 80 { break } + let delta = max(-250, min(250, viewport.frame.midY - asset.frame.midY)) + let margin = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)) + margin.press(forDuration: 0.1, thenDragTo: margin.withOffset(CGVector(dx: 0, dy: delta)), withVelocity: .slow, thenHoldForDuration: 0.5) + } + var previous = asset.frame + var stableSince = Date() + let settled = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + if abs(asset.frame.midY - previous.midY) > 0.5 {previous = asset.frame; stableSince = Date()} + return Date().timeIntervalSince(stableSince) > 0.5 + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 8), .completed) + let zoomedY = asset.frame.midY + let zoomedStart = asset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + zoomedStart.press(forDuration: 0.1, thenDragTo: zoomedStart.withOffset(CGVector(dx: 0, dy: 60)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.midY - zoomedY, 60, accuracy: 6) + let oldSize = asset.frame.size + let resize = element(app, resizeID) + XCTAssertTrue(resize.waitForExistence(timeout: 5)) + XCTAssertTrue(resize.isHittable) + let corner = resize.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: 50, dy: 40)), withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.width - oldSize.width, 50, accuracy: 6) + XCTAssertEqual(asset.frame.height - oldSize.height, 40, accuracy: 6) + tap(app, "Undo") + XCTAssertEqual(asset.frame.width, oldSize.width, accuracy: 2) + tap(app, "Redo") + XCTAssertEqual(asset.frame.width - oldSize.width, 50, accuracy: 6) + // Put the resized asset back across the page break before persisting. + let actualZoom = oldSize.width / initialWidth + XCTAssertGreaterThan(actualZoom, 1.8, "Pinch must actually zoom the asset before testing scaled gestures") + XCTAssertLessThan(actualZoom, 2.2) + let yBeforeMoveBack = asset.frame.midY + let moveBack = asset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + moveBack.press(forDuration: 0.1, thenDragTo: moveBack.withOffset(CGVector(dx: 0, dy: -180)), + withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.midY - yBeforeMoveBack, -180, accuracy: 6) + let crossTopBeforeResize = asset.frame.minY + let crossCorner = resize.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + crossCorner.press(forDuration: 0.1, thenDragTo: crossCorner.withOffset(CGVector(dx: 20, dy: 20)), + withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertEqual(asset.frame.minY, crossTopBeforeResize, accuracy: 2, + "Resizing a crossing image must not clamp it back into its owning page") + let savedWidth = asset.frame.width / actualZoom + let savedHeight = asset.frame.height / actualZoom + let savedTop = (asset.frame.minY - element(app, "ink-page-boundary-1").frame.midY) / actualZoom + XCTAssertLessThan(savedTop, -20) + XCTAssertGreaterThan(savedTop + savedHeight, 0) + attach(app, "OpenNotes crossing asset redo") + tap(app, "Library") + XCTAssertTrue(element(app, noteID).waitForExistence(timeout: 15)) + app.terminate() + launchPrepared(app) + tap(app, noteID) + XCTAssertTrue(viewport.waitForExistence(timeout: 20)) + let disableAfterReload = element(app, "Disable finger drawing") + if disableAfterReload.exists { disableAfterReload.tap() } + viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.8)) + .press(forDuration: 0.1, thenDragTo: viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.4)), + withVelocity: .slow, thenHoldForDuration: 0.5) + XCTAssertTrue(asset.waitForExistence(timeout: 10)) + let restoredBoundary = element(app, "ink-page-boundary-1") + XCTAssertTrue(restoredBoundary.waitForExistence(timeout: 10)) + XCTAssertEqual(asset.frame.width, savedWidth, accuracy: 3) + XCTAssertEqual(asset.frame.height, savedHeight, accuracy: 3) + XCTAssertEqual(asset.frame.minY - restoredBoundary.frame.midY, savedTop, accuracy: 3) + attach(app, "Crossing asset survives full restart") + tap(app, "Export") + XCTAssertTrue(element(app, "Copy").waitForExistence(timeout: 30)) + attach(app, "Crossing asset PDF export") + } + + func testDenseToolsPerformance() throws { + let app = prepare() + try measureInteractions(app) { + for tool in ["eraser", "pen", "highlighter", "pen", "eraser", "pen"] { + tap(app, "tool-\(tool)") + XCTAssertTrue(element(app, "tool-\(tool)").isSelected) + } + } + } + + func testDensePagesPanelPerformance() throws { + let app = prepare() + try measureInteractions(app) { + for _ in 0..<4 { + tap(app, "Pages") + tap(app, "Close pages") + } + } + } + + func testDenseNoteNavigationPerformance() throws { + let app = prepare() + try measureInteractions(app) { + for count in [1, 100, 1, 100] { + tap(app, "editor-back") + tap(app, "note-audit-performance-\(count)-pages") + XCTAssertTrue(element(app, "ink-viewport").waitForExistence(timeout: 20)) + assertDenseInkVisible(app, context: "Opened \(count)-page note") + } + } + } + + // Each pause outlasts the 350 ms autosave debounce, so every stroke after the + // first is drawn while the dense 100-page document is being captured. + func testDenseWritingDuringAutosavePerformance() throws { + let app = prepare() + let viewport = element(app, "ink-viewport") + tap(app, "Enable finger drawing") + try measureInteractions(app) { + for row in 0..<6 { + let y = 0.3 + Double(row) * 0.08 + let start = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: y)) + let end = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: y + 0.02)) + start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .default, thenHoldForDuration: 0) + Thread.sleep(forTimeInterval: 0.4) + } + for _ in 0..<6 { tap(app, "Undo") } + } + assertDenseInkVisible(app, context: "Writing during autosave complete") + } + + func testOverviewAndOneFingerNavigation() throws { + let app = prepare() + let viewport = element(app, "ink-viewport") + viewport.pinch(withScale: 0.3, velocity: -1) + assertDenseInkVisible(app, context: "Lecture overview") + let first = element(app, "ink-page-boundary-1") + let second = element(app, "ink-page-boundary-2") + XCTAssertTrue(first.waitForExistence(timeout: 10)) + XCTAssertTrue(second.waitForExistence(timeout: 10)) + XCTAssertTrue(viewport.frame.intersects(first.frame)) + XCTAssertTrue(viewport.frame.intersects(second.frame), "Overview must show multiple pages") + let before = first.frame.midY + viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.8)) + .press(forDuration: 0.05, thenDragTo: viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.4)), + withVelocity: .slow, thenHoldForDuration: 0) + XCTAssertLessThan(first.frame.midY, before - 100, "One finger must navigate the lecture") + assertDenseInkVisible(app, context: "One-finger overview navigation") + attach(app, "Lecture overview after one-finger pan") + } + + func testDenseScrollAndZoomPerformance() throws { + let app = prepare() + let viewport = element(app, "ink-viewport") + try measureInteractions(app) { + viewport.pinch(withScale: 2, velocity: 1) + viewport.pinch(withScale: 0.5, velocity: -1) + for _ in 0..<6 { viewport.swipeUp() } + for _ in 0..<6 { viewport.swipeDown() } + assertDenseInkVisible(app, context: "Measured scroll/zoom complete") + } + } +} diff --git a/ios/OpenNotesUITests/NotebookStallUITests.swift b/ios/OpenNotesUITests/NotebookStallUITests.swift new file mode 100644 index 0000000..3237dde --- /dev/null +++ b/ios/OpenNotesUITests/NotebookStallUITests.swift @@ -0,0 +1,69 @@ +import XCTest + +final class NotebookStallUITests: NotebookUITestCase { + private let bundle = "com.builderpro.opennotes" + + func testDensePagesAndKeyboardInteractionAudit() { + let app = prepare() + let pagesFrame = element(app, "Pages").frame + let pagesPoint = NativeCanvasAudit.point(app, CGPoint(x: pagesFrame.midX, y: pagesFrame.midY)) + for _ in 0..<3 { + NativeCanvasAudit.window(bundle: bundle) { pagesPoint.tap() } + let input = app.textFields.firstMatch + XCTAssertTrue(input.exists) + let inputFrame = input.frame + let inputPoint = NativeCanvasAudit.point(app, CGPoint(x: inputFrame.midX, y: inputFrame.midY)) + NativeCanvasAudit.window(bundle: bundle) { inputPoint.tap() } + let one = app.keys["1"].firstMatch + XCTAssertTrue(one.exists) + let keyFrame = one.frame + let keyPoint = NativeCanvasAudit.point(app, CGPoint(x: keyFrame.midX, y: keyFrame.midY)) + NativeCanvasAudit.window(bundle: bundle) { keyPoint.tap() } + let closeFrame = element(app, "Close pages").frame + let closePoint = NativeCanvasAudit.point(app, CGPoint(x: closeFrame.midX, y: closeFrame.midY)) + NativeCanvasAudit.window(bundle: bundle) { closePoint.tap() } + XCTAssertTrue(element(app, "ink-viewport").exists) + } + assertDenseInkVisible(app, context: "Repeated Pages and keyboard interactions") + tap(app, "editor-back") + XCTAssertTrue(element(app, "note-audit-performance-100-pages").waitForExistence(timeout: 15)) + } + + func testDensePanZoomInteractionAudit() { + let app = prepare() + NativeCanvasAudit.window(bundle: bundle) { + for _ in 0..<3 { + app.pinch(withScale: 2, velocity: 1) + app.pinch(withScale: 0.5, velocity: -1) + for _ in 0..<4 { app.swipeUp() } + for _ in 0..<4 { app.swipeDown() } + } + } + assertDenseInkVisible(app, context: "Repeated dense pan and zoom") + tap(app, "editor-back") + XCTAssertTrue(element(app, "note-audit-performance-100-pages").waitForExistence(timeout: 15)) + } + + func testDenseWritingDuringAutosaveInteractionAudit() { + let app = prepare() + tap(app, "Enable finger drawing") + let bounds = element(app, "ink-viewport").frame + let undoFrame = element(app, "Undo").frame + let undoPoint = NativeCanvasAudit.point(app, CGPoint(x: undoFrame.midX, y: undoFrame.midY)) + NativeCanvasAudit.window(bundle: bundle) { + for _ in 0..<3 { + for row in 0..<6 { + let y = bounds.minY + bounds.height * (0.3 + Double(row) * 0.08) + let start = NativeCanvasAudit.point(app, CGPoint(x: bounds.minX + bounds.width * 0.25, y: y)) + let end = NativeCanvasAudit.point(app, CGPoint(x: bounds.minX + bounds.width * 0.75, y: y + bounds.height * 0.02)) + start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .default, thenHoldForDuration: 0) + Thread.sleep(forTimeInterval: 0.4) + } + for _ in 0..<6 { undoPoint.tap() } + } + } + assertDenseInkVisible(app, context: "Drawing throughout repeated autosaves") + tap(app, "editor-back") + XCTAssertTrue(element(app, "note-audit-performance-100-pages").waitForExistence(timeout: 15)) + } +} diff --git a/ios/OpenNotesUITests/NotebookUITestCase.swift b/ios/OpenNotesUITests/NotebookUITestCase.swift new file mode 100644 index 0000000..e996066 --- /dev/null +++ b/ios/OpenNotesUITests/NotebookUITestCase.swift @@ -0,0 +1,70 @@ +import XCTest +import UIKit + +class NotebookUITestCase: XCTestCase { + override func setUpWithError() throws { continueAfterFailure = false } + + func element(_ app: XCUIApplication, _ id: String) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: id).firstMatch + } + + func tap(_ app: XCUIApplication, _ id: String) { + let item = element(app, id) + XCTAssertTrue(item.waitForExistence(timeout: 15), "Missing \(id)") + if id.hasPrefix("note-") { + for _ in 0..<6 { + if item.isHittable { break } + app.swipeUp() + } + XCTAssertTrue(item.isHittable, "Library note is offscreen: \(id)") + } + item.tap() + } + + func launchPrepared(_ app: XCUIApplication) { + app.launchEnvironment["MOBILE_INK_AUDIT"] = "1" + app.launchEnvironment["MOBILE_INK_AUDIT_SCENARIO"] = name + app.launch() + let skip = element(app, "Skip introduction") + if skip.waitForExistence(timeout: 2) { skip.tap() } + } + + func prepare() -> XCUIApplication { + NativeCanvasAudit.useRunLoopQuiescence() + let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + launchPrepared(app) + let communityDismiss = app.buttons["Not now"] + if communityDismiss.waitForExistence(timeout: 2) { communityDismiss.tap() } + let skip = element(app, "Skip introduction") + if skip.waitForExistence(timeout: 3) { skip.tap() } + NativeCanvasAudit.startup(bundle: "com.builderpro.opennotes") { + tap(app, "note-audit-performance-100-pages") + } + XCTAssertTrue(element(app, "ink-viewport").waitForExistence(timeout: 20)) + XCTAssertTrue(element(app, "tool-pen").waitForExistence(timeout: 20)) + let finger = element(app, "Disable finger drawing") + if finger.exists { finger.tap() } + assertDenseInkVisible(app, context: "OpenNotes full-page fixture") + return app + } + + func attach(_ app: XCUIApplication, _ name: String) { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = name + screenshot.lifetime = .keepAlways + add(screenshot) + } + + func measureInteractions(_ app: XCUIApplication, _ action: () -> Void) throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Hitch metrics require iOS 26") } + let options = XCTMeasureOptions() + options.iterationCount = 3 + var metrics: [XCTMetric] = [XCTCPUMetric(application: app), XCTMemoryMetric(application: app)] + #if compiler(>=6.2) + metrics.append(XCTHitchMetric(application: app)) + #endif + measure(metrics: metrics, options: options, block: action) + } + +} diff --git a/ios/OpenNotesUITests/README.md b/ios/OpenNotesUITests/README.md new file mode 100644 index 0000000..7052738 --- /dev/null +++ b/ios/OpenNotesUITests/README.md @@ -0,0 +1,40 @@ +# Native notebook audit + +These XCTest tests drive the Release app in an iPad simulator. They do not need +Device Hub or a browser. `NotebookInteractionUITests` checks tools, the page +panel, dense-page zoom at 2× and 5×, long scrolling, and navigation between large and small notes. Its performance cases +record app CPU, memory, and available hitch metrics. XCTest gesture synthesis +and idle waits are included; elapsed test time is not tap response latency. + +`NativeCanvasAudit.useRunLoopQuiescence()` changes only the test runner's +animation-idle wait. It retains the app run-loop wait and production animations, +including paused blur animators that would otherwise cause false 60-second +waits. Unsupported XCTest selectors fail explicitly. Native stall and rendering +budgets remain unchanged. + +Build and install the Release test app, then seed and run the simulator audit: + +```sh +xcodebuild build-for-testing -workspace ios/OpenNotes.xcworkspace \ + -scheme OpenNotesIntegrity -configuration Release \ + -derivedDataPath /private/tmp/opennotes-audit-derived \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' \ + CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES IPHONEOS_DEPLOYMENT_TARGET=15.1 +xcrun simctl install SIMULATOR_UUID \ + /private/tmp/opennotes-audit-derived/Build/Products/Release-iphonesimulator/OpenNotes.app +python3 scripts/seed-native-audit.py SIMULATOR_UUID +xcodebuild test-without-building -workspace ios/OpenNotes.xcworkspace \ + -scheme OpenNotesIntegrity -configuration Release \ + -derivedDataPath /private/tmp/opennotes-audit-derived \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' \ + -parallel-testing-enabled NO -collect-test-diagnostics never \ + CODE_SIGNING_ALLOWED=NO +``` + +The seeder replaces only its two `audit-performance-*` notes and preserves the +other catalog entries and bodies. It stops OpenNotes before changing files and +backs up the original catalog once. Fixtures contain 200 multi-point strokes +per page at the editor's 820×1061 point size and 2× native pixel scale. + +Keep fixture revisions identical for before/after comparisons. Simulator and +standalone engine measurements do not establish physical-iPad frame rates. diff --git a/ios/Podfile b/ios/Podfile index fe01f90..87e5edd 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -59,5 +59,16 @@ target 'OpenNotes' do :mac_catalyst_enabled => false, :ccache_enabled => ccache_enabled?(podfile_properties), ) + + # Resource bundle targets must honor the app's existing iOS 15.1 floor. + # Xcode 27 rejects older pod defaults such as AsyncStorage's iOS 13.4. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + minimum = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if minimum && Gem::Version.new(minimum) < Gem::Version.new('15.1') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1' + end + end + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 03d4228..dee65c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -259,7 +259,7 @@ PODS: - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) - hermes-engine/Pre-built (0.81.5) - - MathNotesMobileInk (0.3.6): + - MathNotesMobileInk (0.3.10): - React-Core - react-native-skia - RCTDeprecation (0.81.5) @@ -2552,7 +2552,7 @@ SPEC CHECKSUMS: EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 - MathNotesMobileInk: affc434b8ea8baee9686500bcc89fa5c40ac861c + MathNotesMobileInk: 844d74c93ca7c3c5379aa648b48100ce99735861 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c @@ -2626,6 +2626,6 @@ SPEC CHECKSUMS: RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0 Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a -PODFILE CHECKSUM: 082bca60b719647a69d16b406d5f83b3106e6bbd +PODFILE CHECKSUM: ec634150cc3f1d91d2160f5abf4615f51a67fc00 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index a46c8b0..ed1f4c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,11 @@ "": { "name": "open-notes", "version": "1.0.0", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@expo/vector-icons": "^15.0.3", - "@mathnotes/mobile-ink": "0.3.6", + "@mathnotes/mobile-ink": "file:vendor/mathnotes-mobile-ink-0.3.10.tgz", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/react-native-skia": "2.2.12", "expo": "54.0.34", @@ -2565,9 +2566,9 @@ } }, "node_modules/@mathnotes/mobile-ink": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.6.tgz", - "integrity": "sha512-XrRL8JVEoG1YuyiRCiP+jvVpD6fP/LhAQKDddQc2tTf3ln6Wuz/w3/T437IgrOhrEk6+uWz9IcC164PqtMqHEA==", + "version": "0.3.10", + "resolved": "file:vendor/mathnotes-mobile-ink-0.3.10.tgz", + "integrity": "sha512-tMy6pTdzd2c3Gk+NkRPtjfHQaJm1YAi5swdi2eN8BGXsHdwgPIZa9yEILls+DG57cWWGKCQzF3H2ljqKnRRcfg==", "license": "Apache-2.0", "peerDependencies": { "@shopify/react-native-skia": ">=2.0.0", @@ -3526,7 +3527,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4546,7 +4547,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -7697,6 +7698,26 @@ } } }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT", + "peer": true + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -8921,7 +8942,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 883fe2a..2bbc7f7 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,14 @@ "android": "expo run:android", "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs", "test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs", - "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs", + "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs scripts/autosaveQueue.test.mjs scripts/continuousAssets.test.mjs scripts/nativeCallback.test.mjs scripts/overlayGeometry.test.mjs scripts/overlayHistory.test.mjs scripts/overlayTouchRects.test.mjs scripts/ci/verify-canvas-audit.test.mjs", "test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs", - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "tsc --noEmit -p tsconfig.json", + "postinstall": "node scripts/ios/fix-expo-view-construction.mjs && node scripts/ios/fix-worklets-simulator-lookup.mjs" }, "dependencies": { "@expo/vector-icons": "^15.0.3", - "@mathnotes/mobile-ink": "0.3.6", + "@mathnotes/mobile-ink": "file:vendor/mathnotes-mobile-ink-0.3.10.tgz", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/react-native-skia": "2.2.12", "expo": "54.0.34", diff --git a/scripts/autosaveQueue.test.mjs b/scripts/autosaveQueue.test.mjs new file mode 100644 index 0000000..b77f1b4 --- /dev/null +++ b/scripts/autosaveQueue.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {createAutosaveQueue} from '../src/hooks/autosaveQueue.ts'; + +const wait = ms => new Promise(done => setTimeout(done, ms)); +const superseded = error => error.message === 'Document changed during capture'; +const harness = (save) => { + const statuses = []; + const queue = createAutosaveQueue({save, onStatus: s => statuses.push(s), isEnabled: () => true, debounceMs: 5, + isSuperseded: superseded}); + return {queue, statuses}; +}; + +test('a change during a save waits for it instead of writing concurrently', async () => { + const order = []; + let release; + let calls = 0; + const {queue} = harness(async () => { + const call = ++calls; + order.push(`start ${call}`); + if (call === 1) await new Promise(done => {release = done;}); + order.push(`end ${call}`); + }); + queue.schedule(); + await wait(15); + queue.schedule(); + await wait(15); + assert.deepEqual(order, ['start 1']); + release(); + await queue.flushNow(); + assert.deepEqual(order, ['start 1', 'end 1', 'start 2', 'end 2']); +}); + +test('a save invalidated by newer ink reports pending, then saves the newer ink', async () => { + let calls = 0; + let queueRef; + const {queue, statuses} = harness(async () => { + if (++calls === 1) { + queueRef.schedule(); + throw new Error('Document changed during capture'); + } + }); + queueRef = queue; + queue.schedule(); + await wait(15); + assert.equal(statuses.includes('error'), false); + await wait(15); + await queue.flushNow(); + assert.equal(calls, 2); + assert.equal(statuses.at(-1), 'saved'); +}); + +test('a failure with no newer change is reported and retried on flush', async () => { + let calls = 0; + const {queue, statuses} = harness(async () => { + if (++calls === 1) throw new Error('disk full'); + }); + queue.schedule(); + await wait(15); + assert.equal(statuses.at(-1), 'error'); + assert.equal(await queue.flushNow(), true); + assert.equal(calls, 2); + assert.equal(statuses.at(-1), 'saved'); +}); + +test('a real failure is reported even while newer ink is pending', async () => { + let calls = 0; + let queueRef; + const {queue, statuses} = harness(async () => { + if (++calls === 1) { + queueRef.schedule(); + throw new Error('disk full'); + } + }); + queueRef = queue; + queue.schedule(); + await wait(15); + assert.ok(statuses.includes('error')); + await queue.flushNow(); + assert.equal(statuses.at(-1), 'saved'); +}); + +test('flush without changes does not save', async () => { + let calls = 0; + const {queue} = harness(async () => { calls++; }); + assert.equal(await queue.flushNow(), true); + assert.equal(calls, 0); +}); diff --git a/scripts/ci/collect-canvas-audits.py b/scripts/ci/collect-canvas-audits.py new file mode 100644 index 0000000..b02e78f --- /dev/null +++ b/scripts/ci/collect-canvas-audits.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Collect timing-only reports on an isolated CI runner, even after Xcode shuts down its simulator.""" +from pathlib import Path +import shutil +import sys + +if len(sys.argv) != 2: + raise SystemExit("Usage: collect-canvas-audits.py OUTPUT_DIRECTORY") +destination = Path(sys.argv[1]) +destination.mkdir(parents=True, exist_ok=True) +root = Path.home() / "Library/Developer/CoreSimulator/Devices" +reports = list(root.glob("*/data/Containers/Data/Application/*/Library/Caches/MobileInkAudits/*.json")) +for report in reports: + shutil.copy2(report, destination / report.name) +print(f"Collected {len(reports)} native timing reports") diff --git a/scripts/ci/resolve-ios-simulator.mjs b/scripts/ci/resolve-ios-simulator.mjs new file mode 100644 index 0000000..05964cc --- /dev/null +++ b/scripts/ci/resolve-ios-simulator.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process'; + +const preferredDeviceNames = [ + 'iPhone 17 Pro', + 'iPhone 17', + 'iPhone 16 Pro', + 'iPhone 16 Plus', + 'iPhone 16', + 'iPhone 15 Pro', + 'iPhone 15 Plus', + 'iPhone 15', + 'iPhone 14 Pro', + 'iPhone 14', + 'iPhone SE (3rd generation)', +]; + +function parseVersion(runtimeKey) { + const match = runtimeKey.match(/iOS[- ](\d+)(?:[.-](\d+))?(?:[.-](\d+))?/i); + if (!match) { + return [0, 0, 0]; + } + + return match.slice(1).map((part) => Number(part ?? 0)); +} + +function compareVersionsDescending(left, right) { + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const delta = (right[index] ?? 0) - (left[index] ?? 0); + if (delta !== 0) { + return delta; + } + } + + return 0; +} + +function getPreferenceRank(name) { + const rank = preferredDeviceNames.indexOf(name); + return rank === -1 ? preferredDeviceNames.length : rank; +} + +function chooseSimulator() { + const raw = execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { + encoding: 'utf8', + }); + const data = JSON.parse(raw); + // XCTest and simctl must come from a toolchain that supports the runtime. + const sdkVersion = execFileSync('xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-version'], { + encoding: 'utf8', + }).trim().split('.').map(Number); + if (!Number.isFinite(sdkVersion[0])) throw new Error('Cannot determine the installed iOS simulator SDK'); + const candidates = []; + const family = process.env.IOS_SIMULATOR_FAMILY; + if (family && family !== 'iphone' && family !== 'ipad') { + throw new Error('IOS_SIMULATOR_FAMILY must be iphone or ipad'); + } + + for (const [runtimeKey, devices] of Object.entries(data.devices ?? {})) { + const runtimeVersion = parseVersion(runtimeKey); + if (runtimeVersion[0] > sdkVersion[0] || + (runtimeVersion[0] === sdkVersion[0] && runtimeVersion[1] > (sdkVersion[1] ?? 0))) continue; + for (const device of devices ?? []) { + if (device.isAvailable === false || !device.udid || !device.name) { + continue; + } + + const isIpad = device.deviceTypeIdentifier?.includes('.iPad-') || /^iPad\b/.test(device.name); + const isIphone = device.deviceTypeIdentifier?.includes('.iPhone-') || /^iPhone\b/.test(device.name); + if (family === 'ipad' && !isIpad) continue; + if (family === 'iphone' && !isIphone) continue; + + candidates.push({ + name: device.name, + runtimeKey, + runtimeVersion, + state: device.state, + udid: device.udid, + isIphone, + }); + } + } + + candidates.sort((left, right) => { + const versionComparison = compareVersionsDescending(left.runtimeVersion, right.runtimeVersion); + if (versionComparison !== 0) { + return versionComparison; + } + + if (left.isIphone !== right.isIphone) { + return left.isIphone ? -1 : 1; + } + + if (left.state !== right.state) { + return left.state === 'Booted' ? -1 : 1; + } + + const rankDelta = getPreferenceRank(left.name) - getPreferenceRank(right.name); + if (rankDelta !== 0) { + return rankDelta; + } + + return left.name.localeCompare(right.name); + }); + + const selected = candidates[0]; + if (!selected) { + throw new Error(`No available ${family ?? 'iOS'} simulators were reported by xcrun simctl.`); + } + + return selected; +} + +try { + const selected = chooseSimulator(); + if (process.env.CI === '1') { + console.error(`Using simulator ${selected.name} on ${selected.runtimeKey} (${selected.udid})`); + } + process.stdout.write(`platform=iOS Simulator,id=${selected.udid}`); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/ci/sample-canvas-process.py b/scripts/ci/sample-canvas-process.py new file mode 100644 index 0000000..3d1ebde --- /dev/null +++ b/scripts/ci/sample-canvas-process.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Sample only the owned audit app during a failed-budget diagnostic rerun.""" +from pathlib import Path +import signal +import subprocess +import sys +import time + +if len(sys.argv) != 3: + raise SystemExit("Usage: sample-canvas-process.py APP_BINARY OUTPUT_DIRECTORY") +binary, output = sys.argv[1], Path(sys.argv[2]) +output.mkdir(parents=True, exist_ok=True) +seen = set() +sampled = 0 +stopping = False +active = None + +def stop(_signum, _frame): + global stopping + stopping = True + if active is not None and active.poll() is None: + active.send_signal(signal.SIGINT) + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +deadline = time.monotonic() + 300 +# Ignore an app left open by the preceding timing run. +initial = subprocess.check_output(["ps", "-axo", "pid=,comm="], text=True) +for line in initial.splitlines(): + fields = line.strip().split(maxsplit=1) + if len(fields) == 2 and fields[1].endswith(f"/{binary}.app/{binary}"): + seen.add(int(fields[0])) +try: + while not stopping and time.monotonic() < deadline and sampled < 3: + listing = subprocess.check_output(["ps", "-axo", "pid=,comm="], text=True) + for line in listing.splitlines(): + fields = line.strip().split(maxsplit=1) + if len(fields) != 2 or not fields[1].endswith(f"/{binary}.app/{binary}"): + continue + pid = int(fields[0]) + if pid in seen: + continue + seen.add(pid) + sampled += 1 + report = output / f"{binary}-{pid}.sample.txt" + print(f"Sampling owned canvas app PID {pid}", flush=True) + active = subprocess.Popen(["sample", str(pid), "90", "5", "-file", str(report)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + active.wait() + active = None + break + if not stopping: + time.sleep(0.2) +finally: + if active is not None and active.poll() is None: + active.send_signal(signal.SIGINT) + active.wait(timeout=5) diff --git a/scripts/ci/select-ios-toolchain.py b/scripts/ci/select-ios-toolchain.py new file mode 100644 index 0000000..780fa6a --- /dev/null +++ b/scripts/ci/select-ios-toolchain.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Choose the newest installed stable Xcode for the isolated native audit.""" +from pathlib import Path +import re + +candidates = [] +for app in Path("/Applications").glob("Xcode_*.app"): + match = re.fullmatch(r"Xcode_(\d+(?:\.\d+)*)\.app", app.name) + if match: + candidates.append((tuple(int(part) for part in match[1].split(".")), app)) +selected = max(candidates)[1] if candidates else Path("/Applications/Xcode.app") +developer = selected / "Contents/Developer" +if not developer.is_dir(): + raise SystemExit("No installed stable Xcode was found") +print(developer) diff --git a/scripts/ci/verify-canvas-audit.mjs b/scripts/ci/verify-canvas-audit.mjs new file mode 100644 index 0000000..cb23642 --- /dev/null +++ b/scripts/ci/verify-canvas-audit.mjs @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const scenarios = ['testDensePagesAndKeyboardInteractionAudit', 'testDensePanZoomInteractionAudit', + 'testDenseWritingDuringAutosaveInteractionAudit']; + +export function verifyCanvasAudit(reports) { + const results = []; + for (const scenario of scenarios) { + const matching = reports.filter(report => typeof report.scenario === 'string' && report.scenario.includes(scenario)); + const startups = matching.filter(report => report.phase === 'startup'); + const interactions = matching.filter(report => report.phase === 'interaction'); + const expectedWindows = scenario.includes('PagesAndKeyboard') ? 12 : 1; + if (!startups.length || interactions.length < expectedWindows) { + throw new Error(`Missing native startup or interaction recordings: ${scenario}`); + } + for (const report of matching) { + const delay = report.mainQueueDelayMs; + const render = report.renderMs; + const needsInkFrames = report.phase === 'startup' || !scenario.includes('PagesAndKeyboard'); + if (!['startup', 'interaction'].includes(report.phase) || report.completed !== true || + report.initialLoadPresented !== true || !delay || !render || + !Number.isFinite(delay.count) || delay.count < 20 || !Number.isFinite(delay.max) || + !Number.isFinite(render.count) || !Number.isFinite(report.renderFrameCount) || + render.count !== report.renderFrameCount || (needsInkFrames && (render.count < 1 || report.renderFrameCount < 1)) || + !Number.isFinite(render.max) || !Number.isFinite(render.p95)) { + throw new Error(`Incomplete native timing evidence: ${scenario}`); + } + const result = {scenario, phase: report.phase, frames: report.renderFrameCount, + mainDelayMaxMs: delay.max, renderP95Ms: render.p95, renderMaxMs: render.max, + stalls: report.mainQueueStallCount}; + results.push(result); + console.log(JSON.stringify(result)); + if (delay.max >= 100 || report.mainQueueStallCount !== 0 || render.max >= 100) { + throw new Error(`Main-thread or rendering stall exceeded 100 ms: ${scenario}`); + } + if (report.phase === 'interaction' && (scenario.includes('PanZoom') || scenario.includes('Writing')) && render.p95 > 16.7) { + throw new Error(`Native gesture rendering exceeded the 60 Hz frame budget: ${scenario}`); + } + } + } + return results; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const directory = process.argv[2]; + if (!directory) throw new Error('Usage: verify-canvas-audit.mjs REPORT_DIRECTORY'); + const reports = fs.readdirSync(directory).filter(name => name.endsWith('.json')) + .map(name => JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8'))); + verifyCanvasAudit(reports); +} diff --git a/scripts/ci/verify-canvas-audit.test.mjs b/scripts/ci/verify-canvas-audit.test.mjs new file mode 100644 index 0000000..fb88c37 --- /dev/null +++ b/scripts/ci/verify-canvas-audit.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyCanvasAudit } from './verify-canvas-audit.mjs'; +const names = ['testDensePagesAndKeyboardInteractionAudit', 'testDensePanZoomInteractionAudit', + 'testDenseWritingDuringAutosaveInteractionAudit']; +const report = (scenario, phase) => ({scenario, phase, completed: true, initialLoadPresented: true, + renderFrameCount: 60, mainQueueDelayMs: {count: 100, max: 2}, mainQueueStallCount: 0, + renderMs: {count: 60, max: 12, p95: 8}}); +const recordings = () => names.flatMap(scenario => [report(scenario, 'startup'), + ...Array.from({length: scenario.includes('PagesAndKeyboard') ? 12 : 1}, () => report(scenario, 'interaction'))]); +const pan = reports => reports.find(r => r.scenario === names[1] && r.phase === 'interaction'); +test('requires complete startup and every interaction window, including confirmed first presentation', () => { + assert.throws(() => verifyCanvasAudit([]), /Missing native/); + const reports = recordings(); + reports.splice(1, 1); + assert.throws(() => verifyCanvasAudit(reports), /Missing native/); + const unpresented = recordings(); + unpresented[0].initialLoadPresented = false; + assert.throws(() => verifyCanvasAudit(unpresented), /Incomplete/); + const cancelled = recordings(); + cancelled[0].completed = false; + assert.throws(() => verifyCanvasAudit(cancelled), /Incomplete/); +}); +test('rejects stalls and slow pan/zoom rendering even when UI assertions passed', () => { + const stalled = recordings(); + stalled[0].mainQueueStallCount = 1; + assert.throws(() => verifyCanvasAudit(stalled), /stall exceeded/); + const delayed = recordings(); + delayed[0].mainQueueDelayMs.max = 101; + assert.throws(() => verifyCanvasAudit(delayed), /stall exceeded/); + const slow = recordings(); + pan(slow).renderMs = {count: 60, max: 40, p95: 25}; + assert.throws(() => verifyCanvasAudit(slow), /frame budget/); + const slowWriting = recordings(); + slowWriting.at(-1).renderMs = {count: 60, max: 40, p95: 25}; + assert.throws(() => verifyCanvasAudit(slowWriting), /frame budget/); +}); +test('accepts measured UI-only windows without inventing ink frames', () => { + const reports = recordings(); + for (const r of reports.filter(r => r.scenario === names[0] && r.phase === 'interaction')) { + r.renderFrameCount = 0; + r.renderMs = {count: 0, max: 0, p95: 0}; + } + assert.equal(verifyCanvasAudit(reports).length, 17); +}); diff --git a/scripts/continuousAssets.test.mjs b/scripts/continuousAssets.test.mjs new file mode 100644 index 0000000..e539bea --- /dev/null +++ b/scripts/continuousAssets.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { assetInNotebook, patchForOwner, assetsForPage, indexAssetsByPage, duplicateAssetPosition } from '../src/utils/continuousAssets.ts'; + +test('moving an asset across its owner boundary preserves its identity and local storage', () => { + const original = { id: 'image', y: 950, height: 200 }; + const view = assetInNotebook(original, 2, 1000); + assert.equal(view.y, 2950); + const patch = patchForOwner({ y: 1850, width: 120 }, 2, 1000); + assert.deepEqual(patch, { y: -150, width: 120 }); + assert.equal(assetInNotebook({ ...original, ...patch }, 2, 1000).y, 1850); + assert.equal(original.y, 950); + assert.deepEqual(patchForOwner({ width: 40 }, 2, 1000), { width: 40 }); +}); + +test('a crossing image projects into both pages without changing the saved object', () => { + const image = { id: 'a', x: 50, y: 900, width: 100, height: 200, type: 'image' }; + const pages = [{ insertedElements: [image] }, {}, {}]; + assert.equal(assetsForPage(pages, 0, 1000).insertedElements[0].y, 900); + assert.equal(assetsForPage(pages, 1, 1000).insertedElements[0].y, -100); + assert.equal(assetsForPage(pages, 2, 1000).insertedElements.length, 0); + assert.equal(image.y, 900); + assert.equal(pages[1].insertedElements, undefined); +}); + +test('rotation can cross a boundary even when the unrotated rectangle does not', () => { + const pages = [{ insertedElements: [{ id: 'r', y: 880, width: 400, height: 80, rotation: 90 }] }, {}]; + assert.equal(assetsForPage(pages, 1, 1000).insertedElements.length, 1); +}); + +test('text moved above its owner exports on the earlier page and across its seam', () => { + const box = { id: 't', y: -100, height: 200, width: 100, content: 'hello' }; + const pages = [{}, { textBoxes: [box] }]; + assert.equal(assetsForPage(pages, 0, 1000).textBoxes[0].y, 900); + assert.equal(assetsForPage(pages, 1, 1000).textBoxes[0].y, -100); +}); + + +test('large export indexing visits each asset once and preserves exact seam boundaries', () => { + let widthReads = 0; + const pages = Array.from({ length: 1000 }, (_, i) => ({ insertedElements: [{ + id: String(i), y: 900, height: 100, + get width() { widthReads++; return 100; }, + }] })); + const indexed = indexAssetsByPage(pages, 1000); + assert.equal(indexed.length, 1000); + assert.ok(widthReads <= 4000, 'indexing must not revisit all assets for each page'); + assert.ok(indexed.every((page) => page.insertedElements.length === 1)); + assert.equal(indexed[999].insertedElements[0].id, '999'); +}); + +test('invalid geometry fails explicitly instead of producing an incomplete PDF', () => { + assert.throws(() => indexAssetsByPage([{ insertedElements: [{ y: NaN }] }], 1000), /invalid geometry/); +}); + + +test('duplication keeps cross-page and negative-owner positions, bounded by the notebook', () => { + assert.deepEqual(duplicateAssetPosition({ x: 20, y: 950, width: 100, height: 200 }, 0, 600, 1000, 3), { x: 40, y: 970 }); + assert.deepEqual(duplicateAssetPosition({ x: 20, y: -150, width: 100, height: 200 }, 2, 600, 1000, 3), { x: 40, y: -130 }); + assert.deepEqual(duplicateAssetPosition({ x: 550, y: 900, width: 100, height: 200 }, 2, 600, 1000, 3), { x: 500, y: 800 }); +}); diff --git a/scripts/exportService.test.mjs b/scripts/exportService.test.mjs index fbfab44..f949f23 100644 --- a/scripts/exportService.test.mjs +++ b/scripts/exportService.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; +import { indexAssetsByPage } from '../src/utils/continuousAssets.ts'; import { buildPdfHtml } from '../src/services/pdfExportHtml.ts'; const source = ts.transpileModule( @@ -27,6 +28,7 @@ function notebook(pages) { function harness(files = {}, { mergePdfFiles } = {}) { const printCalls = []; const batchCalls = []; + const projectionCalls = []; const fileReads = []; const deletedFiles = []; const mergeCalls = []; @@ -49,6 +51,10 @@ function harness(files = {}, { mergePdfFiles } = {}) { }, }; const mobileInk = { + async projectDocumentPages(payloads, height, indices) { + projectionCalls.push({ payloads, height, indices }); + return indices.map(index => 'projected-' + index); + }, async batchExportPages(...args) { batchCalls.push(args); return args[0].map((_, index) => 'data:image/png;base64,' + (index + 1)); @@ -77,6 +83,7 @@ function harness(files = {}, { mergePdfFiles } = {}) { }; } if (name === '@mathnotes/mobile-ink') return mobileInk; + if (name === '../utils/continuousAssets') return { indexAssetsByPage }; if (name === './pdfExportHtml') { return { buildPdfHtml, @@ -93,6 +100,7 @@ function harness(files = {}, { mergePdfFiles } = {}) { exportNotebookAsPdf: exports.exportNotebookAsPdf, printCalls, batchCalls, + projectionCalls, fileReads, deletedFiles, mergeCalls, @@ -256,3 +264,43 @@ test('base64 page rasters are embedded verbatim instead of escaped copies', () = const unsafe = buildPdfHtml([{ rasterUri: 'file:///a"b.png', images: [], textBoxes: [] }]); assert.ok(unsafe.includes('file:///a"b.png')); }); + +test('cross-page assets survive the boundary between PDF print passes', async () => { + const photo = 'file:///notes/crossing.png'; + const h = harness({ [photo]: 'cGhvdG8=' }); + const data = notebook(Array.from({ length: 9 }, () => ({}))); + data.pages[7].insertedElements = [{ + id: 'crossing', type: 'image', sourceUri: photo, + x: 40, y: 1000, width: 200, height: 200, + }]; + data.pages[7].textBoxes = [{ + id: 'text', content: 'Across the seam', x: 20, y: 1030, + width: 200, height: 120, fontSize: 18, color: '#000000', + }]; + const original = JSON.stringify(data); + const result = await h.exportNotebookAsPdf({ data, filename: 'crossing' }); + assert.equal(result.ok, true); + assert.equal(h.printCalls.length, 2); + assert.match(h.printCalls[0].html, /top:1000px;width:200px/); + assert.match(h.printCalls[1].html, /top:-61px;width:200px/); + assert.match(h.printCalls[1].html, /top:-31px;width:200px/); + assert.match(h.printCalls[1].html, /Across the seam/); + assert.equal(JSON.stringify(data), original); +}); + + +test('each bounded export batch projects from the entire notebook, ignoring stale previews', async () => { + const h = harness(); + const data = notebook(Array.from({ length: 9 }, (_, index) => ({ + data: 'owner-' + index, previewUri: 'data:image/png;base64,stale', + }))); + const result = await h.exportNotebookAsPdf({ data, filename: 'cross-page' }); + assert.equal(result.ok, true); + assert.equal(h.projectionCalls.length, 2); + for (const call of h.projectionCalls) { + assert.equal(call.payloads.length, 9); + assert.equal(call.height, 2122); + } + assert.deepEqual(Array.from(h.projectionCalls[1].indices), [8]); + assert.deepEqual(Array.from(h.batchCalls[1][0]), ['projected-8']); +}); diff --git a/scripts/ios/fix-expo-view-construction.mjs b/scripts/ios/fix-expo-view-construction.mjs new file mode 100644 index 0000000..bb2eb02 --- /dev/null +++ b/scripts/ios/fix-expo-view-construction.mjs @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// Swift protocol-conformance scans can stall first view creation on Xcode 27. +// The concrete Fabric superclass is enough to select the existing Fabric path; +// preserve the protocol/legacy fallbacks for all other exported view types. +const target = path.join(process.cwd(), 'node_modules/expo-modules-core/ios/Core/Views/ViewDefinition.swift'); +if (!fs.existsSync(target)) process.exit(0); +const original = fs.readFileSync(target, 'utf8'); +const marker = '// Prefer the concrete Fabric class before Swift protocol lookup.'; +if (original.includes(marker)) process.exit(0); +const before = ' public func createView(appContext: AppContext) -> AppleView? {\n'; +const expected = before + ' if let expoViewType = ViewType.self as? AnyExpoView.Type {\n#if RCT_NEW_ARCH_ENABLED\n'; +if (!original.includes(expected)) throw new Error('Expo ViewDefinition changed: recheck the Fabric view fast path'); +const after = before + `#if RCT_NEW_ARCH_ENABLED + ${marker} + if let fabricViewType = ViewType.self as? ExpoFabricView.Type { + return AppleView.from(ExpoFabricView.create(viewType: fabricViewType, viewDefinition: self, appContext: appContext)) + } +#endif +`; +fs.writeFileSync(target, original.replace(before, after)); +console.log('[fix-expo-view-construction] Added concrete Fabric view fast path'); diff --git a/scripts/ios/fix-worklets-simulator-lookup.mjs b/scripts/ios/fix-worklets-simulator-lookup.mjs new file mode 100644 index 0000000..4eef53e --- /dev/null +++ b/scripts/ios/fix-worklets-simulator-lookup.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// Simulator slow-animation support looks up a UIKit symbol on the first frame. +// Searching RTLD_DEFAULT can scan every loaded image and block the UI thread. +// Use the already-loaded defining framework first, retaining the original +// fallback for runtimes that expose the symbol elsewhere. Device code is unchanged. +const target = path.join(process.cwd(), 'node_modules/react-native-worklets/apple/worklets/apple/SlowAnimations.mm'); +if (!fs.existsSync(target)) process.exit(0); +const source = fs.readFileSync(target, 'utf8'); +const marker = '// Resolve the simulator animation coefficient in UIKitCore first.'; +if (source.includes(marker)) process.exit(0); +const before = ' UIAnimationDragCoefficient = (float (*)(void))dlsym(RTLD_DEFAULT, "UIAnimationDragCoefficient");'; +if (!source.includes(before)) throw new Error('Worklets slow-animation lookup changed; recheck the scoped UIKit lookup'); +const after = ` ${marker} + void *uiKit = dlopen("/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", RTLD_LAZY | RTLD_NOLOAD); + if (uiKit) { + UIAnimationDragCoefficient = (float (*)(void))dlsym(uiKit, "UIAnimationDragCoefficient"); + dlclose(uiKit); + } + if (!UIAnimationDragCoefficient) { + UIAnimationDragCoefficient = (float (*)(void))dlsym(RTLD_DEFAULT, "UIAnimationDragCoefficient"); + }`; +fs.writeFileSync(target, source.replace(before, after)); +console.log('[fix-worklets-simulator-lookup] Scoped the first-frame UIKit symbol lookup'); diff --git a/scripts/nativeCallback.test.mjs b/scripts/nativeCallback.test.mjs new file mode 100644 index 0000000..c42f900 --- /dev/null +++ b/scripts/nativeCallback.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createNativeCallback} from '../src/hooks/native-callback/index.ts'; + +test('retained native dispatchers use the latest consumer and release closed editors', () => { + const bridge = createNativeCallback(); + const retained = bridge.invoke; + const first = [], second = []; + bridge.update(value => first.push(value)); + retained(1); + bridge.update(value => second.push(value)); + retained(2); + bridge.update(null); + retained(3); + assert.deepEqual(first, [1]); + assert.deepEqual(second, [2]); +}); + +test('callback failures propagate rather than being silently ignored', () => { + const bridge = createNativeCallback(); + bridge.update(() => { throw new Error('save failed'); }); + assert.throws(() => bridge.invoke(), /save failed/); +}); diff --git a/scripts/overlayGeometry.test.mjs b/scripts/overlayGeometry.test.mjs new file mode 100644 index 0000000..fcd41cb --- /dev/null +++ b/scripts/overlayGeometry.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {resizeFromCorner} from '../src/components/editor/overlayGeometry.ts'; + +test('resizing overflow retains its owner-relative negative top', () => { + assert.deepEqual(resizeFromCorner('bottom-right', 280, -70, 180, 140, 25, 20, 820, false), + {x: 280, y: -70, width: 205, height: 160}); +}); +test('top-left resizing preserves opposite corner across a page break', () => { + const next = resizeFromCorner('top-left', 280, -70, 180, 140, -20, -30, 820, false); + assert.deepEqual(next, {x: 260, y: -100, width: 200, height: 170}); + assert.equal(next.y + next.height, 70); +}); +test('aspect-ratio resize can span more than one page', () => { + const next = resizeFromCorner('bottom-right', 0, -100, 400, 1000, 400, 1000, 820, true); + assert.equal(next.y, -100); + assert.equal(next.width, 800); + assert.equal(next.height, 2000); +}); +test('minimum size and horizontal boundary remain enforced', () => { + const next = resizeFromCorner('top-left', 280, -70, 180, 140, 1000, 1000, 820, false); + assert.equal(next.width, 50); assert.equal(next.height, 50); + assert.equal(next.x + next.width, 460); assert.equal(next.y + next.height, 70); +}); + +import {localPointerDelta, anchorRotatedFrame, cropFromEdge} from '../src/components/editor/overlayGeometry.ts'; +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`); +function corner(frame, u, v, degrees) { + const angle = degrees * Math.PI / 180; + const x = (u - .5) * frame.width, y = (v - .5) * frame.height; + return {x: frame.x + frame.width / 2 + x * Math.cos(angle) - y * Math.sin(angle), + y: frame.y + frame.height / 2 + x * Math.sin(angle) + y * Math.cos(angle)}; +} +for (const angle of [0, 45, 90, 180, 270]) { + test(`rotated resize at ${angle} degrees fixes the opposite corner`, () => { + const before = {x: 280, y: -70, width: 180, height: 140}; + for (const handle of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { + const dx = handle.includes('left') ? -25 : 25, dy = handle.includes('top') ? -20 : 20; + const radians = angle * Math.PI / 180; + const delta = localPointerDelta((dx * Math.cos(radians) - dy * Math.sin(radians)) * 2, + (dx * Math.sin(radians) + dy * Math.cos(radians)) * 2, 2, angle); + near(delta.x, dx); near(delta.y, dy); + const local = resizeFromCorner(handle, before.x, before.y, before.width, before.height, delta.x, delta.y, 820, false); + const next = anchorRotatedFrame(before, local, angle); + const u = handle.includes('left') ? 1 : 0, v = handle.includes('top') ? 1 : 0; + const oldCorner = corner(before, u, v, angle), newCorner = corner(next, u, v, angle); + near(oldCorner.x, newCorner.x); near(oldCorner.y, newCorner.y); + near(next.width, 205); near(next.height, 160); + } + }); + test(`crop and uncrop at ${angle} degrees preserve image alignment`, () => { + const before = {x: 280, y: -70, width: 180, height: 140}; + const cropped = cropFromEdge(before, {x: 0, y: 0, width: 1, height: 1}, 'top-left', 25, 20, angle); + const oldCorner = corner(before, 1, 1, angle), newCorner = corner(cropped.frame, 1, 1, angle); + near(oldCorner.x, newCorner.x); near(oldCorner.y, newCorner.y); + near(cropped.crop.width * 180, cropped.frame.width); + near(cropped.crop.height * 140, cropped.frame.height); + const restored = cropFromEdge(cropped.frame, cropped.crop, 'top-left', -1000, -1000, angle); + for (const key of ['x', 'y', 'width', 'height']) near(restored.frame[key], before[key]); + near(restored.crop.x, 0); near(restored.crop.y, 0); + near(restored.crop.width, 1); near(restored.crop.height, 1); + }); +} +test('crop minimum stays synchronized with normalized crop for large assets', () => { + const next = cropFromEdge({x: 0, y: -20, width: 4000, height: 3000}, + {x: 0, y: 0, width: 1, height: 1}, 'bottom-right', -10000, -10000, 0); + near(next.crop.width, .02); near(next.crop.height, .02); + near(next.frame.width, 80); near(next.frame.height, 60); +}); diff --git a/scripts/overlayHistory.test.mjs b/scripts/overlayHistory.test.mjs new file mode 100644 index 0000000..6baf834 --- /dev/null +++ b/scripts/overlayHistory.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {overlayPatches, applyOverlayPatch} from '../src/components/editor/history/patch.ts'; +import {createOverlayTransactions} from '../src/components/editor/history/transactions.ts'; +const fixture = () => [{id: 'page', title: 'Page', rotation: 0, data: 'ink', textBoxes: [ + {id: 'text', x: 0, y: -30, width: 100, content: 'Note'}]}]; +const move = y => pages => pages.map(page => ({...page, textBoxes: page.textBoxes.map(box => ({...box, y}))})); + +test('undo/redo across a page boundary does not revert newly captured ink', () => { + const before = fixture(), after = move(90)(before); + const patch = overlayPatches(before, after); + const newer = after.map(page => ({...page, data: 'new ink'})); + const undone = applyOverlayPatch(newer, patch.before); + assert.equal(undone[0].textBoxes[0].y, -30); + assert.equal(undone[0].data, 'new ink'); + assert.deepEqual(applyOverlayPatch(undone, patch.after), newer); +}); +test('save waits for two queued native edits in order', async () => { + let pages = fixture(), resolve; + const first = new Promise(done => {resolve = done;}); + let calls = 0; + const queue = createOverlayTransactions({read: () => pages, write: next => {pages = next;}, + record: async () => ++calls === 1 ? first : true}); + const a = queue.commit(move(10)); + const saved = queue.flush(); + await Promise.resolve(); + const b = queue.commit(move(20)); + assert.equal(pages[0].textBoxes[0].y, -30); + resolve(true); + await saved; + assert.equal(pages[0].textBoxes[0].y, 20); + assert.equal(queue.hasPending(), false); + await Promise.all([a, b]); +}); +test('rejected native edits leave the saved overlay unchanged', async () => { + let pages = fixture(); + const before = pages; + const queue = createOverlayTransactions({read: () => pages, write: next => {pages = next;}, record: async () => false}); + await assert.rejects(queue.commit(move(20)), /could not record/); + assert.equal(pages, before); +}); +test('invalid patches do not partially mutate source objects', () => { + const pages = fixture(); + const patch = {format: 'opennotes-overlay-1', changes: [ + {pageId: 'page', collection: 'textBoxes', id: 'text', index: 0, set: {y: 20}, unset: []}, + {pageId: 'page', collection: 'textBoxes', id: 'missing', index: 0, set: {y: 20}, unset: []}, + ]}; + assert.throws(() => applyOverlayPatch(pages, JSON.stringify(patch)), /missing/); + assert.equal(pages[0].textBoxes[0].y, -30); +}); diff --git a/scripts/overlayTextInsertion.test.mjs b/scripts/overlayTextInsertion.test.mjs index c4f0e00..846e18c 100644 --- a/scripts/overlayTextInsertion.test.mjs +++ b/scripts/overlayTextInsertion.test.mjs @@ -3,15 +3,18 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; +import * as continuousAssets from '../src/utils/continuousAssets.ts'; // Exercise the real Pressable handler; native locationX/Y are already local // to the transformed overlay, regardless of the viewport zoom and scroll. -function harness(transform, pageCount = 3) { +function harness(transform, pageCount = 3, customPages) { const created = []; const selected = []; + const updates = []; const react = { createElement: (type, props, ...children) => ({ type, props, children }), useCallback: (fn) => fn, + useMemo: (fn) => fn(), useEffect: () => {}, useRef: (current) => ({ current }), useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}], @@ -34,6 +37,7 @@ function harness(transform, pageCount = 3) { } const viewport = load('../src/hooks/useViewportTransform.ts'); const { OverlayLayer } = load('../src/components/editor/OverlayLayer.tsx', { + 'react-native-reanimated': {useSharedValue: value => ({value})}, 'react-native': { View: 'View', Pressable: 'Pressable', Keyboard: { dismiss() {} }, StyleSheet: { absoluteFill: {}, create: (styles) => styles }, @@ -41,18 +45,21 @@ function harness(transform, pageCount = 3) { './TextBoxOverlay': { TextBoxOverlay: 'TextBoxOverlay' }, './ImageInsertOverlay': { ImageInsertOverlay: 'ImageInsertOverlay' }, '../../hooks/useViewportTransform': viewport, + '../../utils/continuousAssets': continuousAssets, }); const tree = OverlayLayer({ store: { getSnapshot: () => transform }, - pages: Array.from({ length: pageCount }, (_, i) => ({ id: String(i) })), + pages: customPages ?? Array.from({ length: pageCount }, (_, i) => ({ id: String(i) })), pageWidth: 820, pageHeight: 1061, activeTool: 'text', activeColor: '#123456', selection: null, onCreateTextBoxId: () => 'new-text', onCreateTextBox: (pageIndex, box) => created.push({ pageIndex, x: box.x, y: box.y }), onSelectionChange: (value) => selected.push(value), + onUpdateInsertedElement: (owner, id, patch) => updates.push({ owner, id, patch }), + onUpdateTextBox: (owner, id, patch) => updates.push({ owner, id, patch }), }); return { - created, selected, + created, selected, tree, updates, tap: (x, y) => tree.children[0].props.onPress({ nativeEvent: { locationX: x, locationY: y } }), }; } @@ -93,3 +100,40 @@ test('taps outside the page content or with invalid coordinates do not create te empty.tap(10, 10); assert.equal(empty.created.length, 0); }); + + +test('cross-page overlays edit the original asset through notebook coordinates', () => { + const pages = [{ id: '0' }, { id: '1', insertedElements: [ + { id: 'image', type: 'image', x: 10, y: 1000, width: 200, height: 200 }, + ], textBoxes: [{ id: 'text', x: 10, y: -50, width: 200, height: 100 }] }, { id: '2' }]; + const h = harness({ scale: 1, translateX: 0, translateY: 0 }, 3, pages); + const host = h.tree.children[1][1]; + const image = host.children[0][0]; + const text = host.children[1][0]; + assert.equal(image.props.element.y, 2061); + assert.equal(text.props.textBox.y, 1011); + assert.equal(image.props.pageHeight, 3183); + image.props.onUpdate({ y: 900 }); + text.props.onCommit({ y: 2000, isEditing: false }); + assert.equal(h.updates[0].owner, 1); + assert.equal(h.updates[0].id, 'image'); + assert.equal(h.updates[0].patch.y, -161); + assert.equal(h.updates[1].patch.y, 939); + assert.equal(pages[1].insertedElements[0].y, 1000); +}); + +test('image layering compares every owner page and does not add page clipping views', () => { + const pages = [ + { id: '0', insertedElements: [{ id: 'a', type: 'image', zIndex: -4 }] }, + { id: '1', insertedElements: [{ id: 'b', type: 'image', zIndex: 20 }] }, + ]; + const h = harness({ scale: 1 }, 2, pages); + assert.equal(h.tree.children[0].props.style[1].zIndex, -5); + const first = h.tree.children[1][0]; + assert.equal(first.props.style, undefined); + first.children[0][0].props.onBringToFront(); + assert.equal(h.updates[0].patch.zIndex, 21); + const second = h.tree.children[1][1]; + second.children[0][0].props.onSendToBack(); + assert.equal(h.updates[1].patch.zIndex, -5); +}); diff --git a/scripts/overlayTouchRects.test.mjs b/scripts/overlayTouchRects.test.mjs new file mode 100644 index 0000000..1cd5456 --- /dev/null +++ b/scripts/overlayTouchRects.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {selectedOverlayTouchRects} from '../src/utils/overlayTouchRects.ts'; +const layout = {pageWidth: 820, pageHeight: 1061, contentPadding: 16, containerWidth: 1020}; +const pages = [{id: 'one'}, {id: 'two', insertedElements: [{id: 'image', x: 280, y: -70, width: 180, height: 140, rotation: 90}], + textBoxes: [{id: 'text', x: 200, y: -100, width: 240, height: 140}]}]; +test('a rotated crossing image excludes its body and controls while leaving the right margin scrollable', () => { + const [rect] = selectedOverlayTouchRects(pages, {type: 'image', pageIndex: 1, id: 'image'}, layout); + assert.deepEqual(rect, {left: 364, right: 576, top: 891, bottom: 1203}); + assert.ok(rect.right < layout.containerWidth - 50); +}); +test('text above its owner page blocks the actual body rather than the owner page', () => { + const [rect] = selectedOverlayTouchRects(pages, {type: 'text', pageIndex: 1, id: 'text'}, layout); + assert.equal(rect.left, 264); + assert.equal(rect.top, 881); + assert.equal(rect.bottom, 1153); +}); +test('no selection and missing/deleted selections do not block navigation', () => { + assert.deepEqual(selectedOverlayTouchRects(pages, null, layout), []); + assert.deepEqual(selectedOverlayTouchRects(pages, {type: 'image', pageIndex: 1, id: 'missing'}, layout), []); +}); diff --git a/scripts/seed-native-audit.py b/scripts/seed-native-audit.py new file mode 100644 index 0000000..640ae70 --- /dev/null +++ b/scripts/seed-native-audit.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Seed owned audit notes into an installed OpenNotes simulator app. + +Preserves all unrelated catalog entries and bodies. The app must be stopped; +only the explicitly named audit fixtures below are replaced. No physical-device writes. +""" +import argparse +import base64 +from datetime import datetime, timezone +import json +import math +from pathlib import Path +import struct +import subprocess +import zlib + +parser = argparse.ArgumentParser() +parser.add_argument('simulator', help='Simulator UUID (not a physical device)') +args = parser.parse_args() +bundle = 'com.builderpro.opennotes' +subprocess.run(['xcrun', 'simctl', 'terminate', args.simulator, bundle], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +container = Path(subprocess.check_output( + ['xcrun', 'simctl', 'get_app_container', args.simulator, bundle, 'data'], text=True).strip()) +documents = container / 'Documents' +catalog_path = documents / 'notes-catalog.json' +catalog = json.loads(catalog_path.read_text()) if catalog_path.exists() else { + 'version': 1, 'notes': [], 'folders': [], 'deletedNoteIds': {}} +if catalog.get('version') != 1 or not isinstance(catalog.get('notes'), list): + raise ValueError('Unexpected catalog shape; leaving it unchanged') + +width, height, scale = 820, 1061, 2 +payload = bytearray(struct.pack('I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) +png = b'\x89PNG\r\n\x1a\n' + png_chunk(b'IHDR', struct.pack('>IIBBBBB', 20, 20, 8, 2, 0, 0, 0)) +png += png_chunk(b'IDAT', zlib.compress((b'\0' + bytes([240, 170, 30]) * 20) * 20)) + png_chunk(b'IEND', b'') +asset_path = asset_dir / 'cross.png' +asset_path.write_bytes(png) +asset_pages = [{'id': f'{asset_id}-{i}', 'title': f'Page {i+1}', 'rotation': 0, + 'data': '{"pages":{}}', 'textBoxes': [], 'insertedElements': []} for i in range(2)] +asset_pages[1]['insertedElements'] = [{'id': 'cross-image', 'type': 'image', 'x': 280, 'y': -70, + 'width': 180, 'height': 140, 'rotation': 0, 'zIndex': 1, 'sourceUri': asset_path.as_uri(), + 'aspectRatio': 180/140, 'createdAt': now, 'updatedAt': now}] +(bodies / f'{asset_id}.body').write_text(json.dumps({'version': '1.0', 'originalCanvasWidth': width, 'pages': asset_pages})) +catalog['notes'] = [note for note in catalog['notes'] if note['id'] != asset_id] +catalog['notes'].append({'id': asset_id, 'title': 'Audit crossing asset', 'folderId': None, + 'createdAt': now, 'updatedAt': now, 'backgroundType': 'plain', 'pdfUri': None, 'thumbnailUri': None}) +catalog.setdefault('deletedNoteIds', {}).pop(asset_id, None) +rotated_id = 'audit-rotated-asset' +rotated_pages = json.loads(json.dumps(asset_pages)) +rotated_pages[1]['insertedElements'][0]['rotation'] = 90 +rotated_dir = documents / 'images' / rotated_id +rotated_dir.mkdir(parents=True, exist_ok=True) +rotated_path = rotated_dir / 'quadrants.png' +colors = [(230, 55, 45), (45, 180, 80), (40, 105, 230), (245, 200, 35)] +rows = bytearray() +for row in range(80): + rows.append(0) + for column in range(80): + color = colors[(2 if row >= 40 else 0) + (1 if column >= 40 else 0)] + rows.extend((30, 30, 30) if column in (0, 39, 40, 79) or row in (0, 39, 40, 79) else color) +rotated_png = b'\x89PNG\r\n\x1a\n' + png_chunk(b'IHDR', struct.pack('>IIBBBBB', 80, 80, 8, 2, 0, 0, 0)) +rotated_png += png_chunk(b'IDAT', zlib.compress(rows)) + png_chunk(b'IEND', b'') +rotated_path.write_bytes(rotated_png) +rotated_pages[1]['insertedElements'][0]['sourceUri'] = rotated_path.as_uri() + +(bodies / f'{rotated_id}.body').write_text(json.dumps({'version': '1.0', 'originalCanvasWidth': width, 'pages': rotated_pages})) +catalog['notes'] = [note for note in catalog['notes'] if note['id'] != rotated_id] +catalog['notes'].append({'id': rotated_id, 'title': 'Audit rotated asset', 'folderId': None, + 'createdAt': now, 'updatedAt': now, 'backgroundType': 'plain', 'pdfUri': None, 'thumbnailUri': None}) +catalog.setdefault('deletedNoteIds', {}).pop(rotated_id, None) +text_id = 'audit-cross-text' +text_pages = [{'id': f'{text_id}-{i}', 'title': f'Page {i+1}', 'rotation': 0, + 'data': '{"pages":{}}', 'textBoxes': [], 'insertedElements': []} for i in range(2)] +text_pages[1]['textBoxes'] = [{'id': 'cross-text', 'x': 280, 'y': -70, 'width': 240, 'height': 140, + 'content': 'Above the edge\nAcross two pages\nOne text box\nBelow the edge', 'fontSize': 22, 'color': '#111827', 'isEditing': False}] +(bodies / f'{text_id}.body').write_text(json.dumps({'version': '1.0', 'originalCanvasWidth': width, 'pages': text_pages})) +catalog['notes'] = [note for note in catalog['notes'] if note['id'] != text_id] +catalog['notes'].append({'id': text_id, 'title': 'Audit crossing text', 'folderId': None, + 'createdAt': now, 'updatedAt': now, 'backgroundType': 'plain', 'pdfUri': None, 'thumbnailUri': None}) +catalog.setdefault('deletedNoteIds', {}).pop(text_id, None) +# Structurally valid notebook with invalid native drawing bytes. Loading must +# show recovery controls without saving blank content over the original file. +invalid_id = 'audit-invalid-native-payload' +invalid_body = {'version': '1.0', 'originalCanvasWidth': width, 'pages': [ + {'id': 'invalid-native-page', 'title': 'Preserve this page', 'rotation': 0, + 'data': '{"pages":{"0":"AA=="}}', 'textBoxes': [], 'insertedElements': []}]} +(bodies / f'{invalid_id}.body').write_text(json.dumps(invalid_body)) +catalog['notes'] = [note for note in catalog['notes'] if note['id'] != invalid_id] +catalog['notes'].append({'id': invalid_id, 'title': 'Audit invalid native payload', 'folderId': None, + 'createdAt': now, 'updatedAt': now, 'backgroundType': 'plain', 'pdfUri': None, 'thumbnailUri': None}) +catalog.setdefault('deletedNoteIds', {}).pop(invalid_id, None) +if catalog_path.exists(): + backup = catalog_path.with_suffix('.before-native-audit.json') + if not backup.exists(): + backup.write_bytes(catalog_path.read_bytes()) +temporary = catalog_path.with_suffix('.audit-tmp') +temporary.write_text(json.dumps(catalog, separators=(',', ':'))) +temporary.replace(catalog_path) +print(f'Seeded performance and cross-page audit fixtures in {documents}') diff --git a/scripts/verify-native-audit.py b/scripts/verify-native-audit.py new file mode 100644 index 0000000..faaa731 --- /dev/null +++ b/scripts/verify-native-audit.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Read the owned 100-page simulator fixture after the native autosave workload. + +Twenty-four strokes are added and undone during the measured writing workload. +The persisted document must still contain exactly its original 20,000 strokes. +""" +import argparse +import base64 +import json +from pathlib import Path +import struct +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument('simulator', help='Simulator UUID, never a physical device') +args = parser.parse_args() +container = Path(subprocess.check_output(['xcrun', 'simctl', 'get_app_container', + args.simulator, 'com.builderpro.opennotes', 'data'], text=True).strip()) +body = json.loads((container / 'Documents/notebook-bodies/audit-performance-100-pages.body').read_text()) +count = 0 +for page in body['pages']: + payload = json.loads(page.get('data') or '{"pages":{}}') + for encoded in payload['pages'].values(): + data = base64.b64decode(encoded, validate=True) + if len(data) < 8: + raise ValueError('Truncated native drawing payload') + version, strokes = struct.unpack_from(' [styles.sideButton, pressed && { opacity: 0.5 }]} diff --git a/src/components/editor/FloatingToolbar.tsx b/src/components/editor/FloatingToolbar.tsx index 2b2452a..22b8ecf 100644 --- a/src/components/editor/FloatingToolbar.tsx +++ b/src/components/editor/FloatingToolbar.tsx @@ -181,6 +181,7 @@ export interface FloatingToolbarProps { onToolLongPress: (tool: ToolDescriptor, anchor: ToolbarButtonAnchor) => void; fingerDrawingEnabled: boolean; onToggleFingerDrawing: () => void; + historyEnabled?: boolean; onUndo: () => void; onRedo: () => void; } @@ -202,6 +203,7 @@ export function FloatingToolbar({ onToolLongPress, fingerDrawingEnabled, onToggleFingerDrawing, + historyEnabled = true, onUndo, onRedo, }: FloatingToolbarProps) { @@ -530,6 +532,7 @@ export function FloatingToolbar({ iconName="arrow-undo-outline" compact={isCompactHorizontal} accessibilityLabel={t.editor.undo} + disabled={!historyEnabled} onPress={onUndo} theme={theme} /> @@ -537,6 +540,7 @@ export function FloatingToolbar({ iconName="arrow-redo-outline" compact={isCompactHorizontal} accessibilityLabel={t.editor.redo} + disabled={!historyEnabled} onPress={onRedo} theme={theme} /> @@ -548,6 +552,7 @@ export function FloatingToolbar({ } function SmallIconButton({ + disabled = false, iconName, active = false, compact = false, @@ -557,6 +562,7 @@ function SmallIconButton({ }: { iconName: keyof typeof Ionicons.glyphMap; active?: boolean; + disabled?: boolean; compact?: boolean; accessibilityLabel: string; onPress: () => void; @@ -564,6 +570,7 @@ function SmallIconButton({ }) { return ( { void Haptics.selectionAsync(); onPress(); @@ -571,9 +578,10 @@ function SmallIconButton({ hitSlop={4} accessibilityRole="button" accessibilityLabel={accessibilityLabel} - accessibilityState={{ selected: active }} + accessibilityState={{ selected: active, disabled }} style={({ pressed }) => [ styles.smallButton, + disabled && { opacity: 0.35 }, compact && styles.smallButtonCompact, { backgroundColor: active diff --git a/src/components/editor/ImageInsertOverlay.tsx b/src/components/editor/ImageInsertOverlay.tsx index d327a7f..e6c64d1 100644 --- a/src/components/editor/ImageInsertOverlay.tsx +++ b/src/components/editor/ImageInsertOverlay.tsx @@ -1,3 +1,5 @@ +import {useNativeCallback} from '../../hooks/native-callback'; +import {resizeFromCorner, localPointerDelta, anchorRotatedFrame, cropFromEdge, type ResizeHandle} from './overlayGeometry'; import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, @@ -23,12 +25,9 @@ import { useTheme } from '../../hooks/useTheme'; const DEFAULT_WIDTH = 240; const DEFAULT_HEIGHT = 240; -const DEFAULT_MIN_SIZE = 50; const HANDLE_SIZE = 24; const HANDLE_OFFSET = -HANDLE_SIZE / 2; -const MIN_CROP_SIZE = 36; -type ResizeHandle = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; type CropHandle = | 'left' | 'right' @@ -44,6 +43,7 @@ type CropRect = NonNullable; export interface ImageInsertOverlayProps { element: InsertedElement; pageScale: number; + gestureScale?: SharedValue; screenLeft: number; screenTop: number; pageWidth: number; @@ -69,68 +69,11 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -function resizeFromCorner( - direction: ResizeHandle, - baseX: number, - baseY: number, - baseWidth: number, - baseHeight: number, - dx: number, - dy: number, - pageWidth: number, - pageHeight: number, - preserveAspectRatio: boolean, -) { - 'worklet'; - const aspectRatio = baseHeight > 0 ? baseWidth / baseHeight : 1; - let nextX = baseX; - let nextY = baseY; - let nextWidth = baseWidth; - let nextHeight = baseHeight; - - if (preserveAspectRatio) { - const rawWidth = direction.includes('left') ? baseWidth - dx : baseWidth + dx; - const rawHeight = direction.includes('top') ? baseHeight - dy : baseHeight + dy; - const widthScale = rawWidth / baseWidth; - const heightScale = rawHeight / baseHeight; - const minScale = Math.max(DEFAULT_MIN_SIZE / baseWidth, DEFAULT_MIN_SIZE / baseHeight); - const uniformScale = Math.max(minScale, Math.max(widthScale, heightScale)); - - nextWidth = Math.min(pageWidth, baseWidth * uniformScale); - nextHeight = Math.min(pageHeight, nextWidth / aspectRatio); - - if (direction.includes('left')) nextX = baseX + (baseWidth - nextWidth); - if (direction.includes('top')) nextY = baseY + (baseHeight - nextHeight); - } else { - if (direction.includes('right')) { - nextWidth = Math.max(DEFAULT_MIN_SIZE, baseWidth + dx); - } - if (direction.includes('left')) { - const widthDelta = Math.min(dx, baseWidth - DEFAULT_MIN_SIZE); - nextWidth = baseWidth - widthDelta; - nextX = baseX + widthDelta; - } - if (direction.includes('bottom')) { - nextHeight = Math.max(DEFAULT_MIN_SIZE, baseHeight + dy); - } - if (direction.includes('top')) { - const heightDelta = Math.min(dy, baseHeight - DEFAULT_MIN_SIZE); - nextHeight = baseHeight - heightDelta; - nextY = baseY + heightDelta; - } - } - - nextX = clamp(nextX, 0, Math.max(0, pageWidth - nextWidth)); - nextY = clamp(nextY, 0, Math.max(0, pageHeight - nextHeight)); - nextWidth = Math.min(nextWidth, pageWidth - nextX); - nextHeight = Math.min(nextHeight, pageHeight - nextY); - - return { x: nextX, y: nextY, width: nextWidth, height: nextHeight }; -} export function ImageInsertOverlay({ element, pageScale, + gestureScale, screenLeft, screenTop, pageWidth, @@ -144,6 +87,7 @@ export function ImageInsertOverlay({ onSendToBack, onRemove, }: ImageInsertOverlayProps) { + const nativeSelect = useNativeCallback(onSelect); const theme = useTheme(); const [isCropMode, setIsCropMode] = useState(false); @@ -151,7 +95,7 @@ export function ImageInsertOverlay({ const initialHeight = element.height ?? DEFAULT_HEIGHT; const locked = Boolean(element.locked); const sourceUri = element.sourceUri ?? element.renderedImageUri; - const canCrop = element.type === 'image' && Boolean(sourceUri); + const canCrop = element.type === 'image' && Boolean(sourceUri) && !locked; const preserveAspectRatio = element.type === 'latex-figure'; const x = useSharedValue(element.x); @@ -202,6 +146,7 @@ export function ImageInsertOverlay({ cropX, cropY, element.cropRect, + element.rotation, element.height, element.rotation, element.width, @@ -227,14 +172,13 @@ export function ImageInsertOverlay({ if (!isSelected) setIsCropMode(false); }, [isSelected]); - const commitPosition = useCallback( + const commitPosition = useNativeCallback( (nextX: number, nextY: number) => { onUpdate({ x: nextX, y: nextY }); }, - [onUpdate], ); - const commitSize = useCallback( + const commitSize = useNativeCallback( (nextWidth: number, nextHeight: number, nextX: number, nextY: number) => { onUpdate({ x: nextX, @@ -243,19 +187,17 @@ export function ImageInsertOverlay({ height: nextHeight, }); }, - [onUpdate], ); - const commitRotation = useCallback( + const commitRotation = useNativeCallback( (nextRotation: number) => { onUpdate({ rotation: nextRotation }); }, - [onUpdate], ); - const selectFromFinger = useCallback(() => { + const selectFromFinger = useNativeCallback(() => { onSelect(); - }, [onSelect]); + }); const handleFingerPointerDown = useCallback((event: any) => { const pointerType = event.nativeEvent.pointerType; @@ -272,8 +214,11 @@ export function ImageInsertOverlay({ const fullWidth = (element.width ?? DEFAULT_WIDTH) / Math.max(0.001, element.cropRect.width); const fullHeight = (element.height ?? DEFAULT_HEIGHT) / Math.max(0.001, element.cropRect.height); - const fullX = element.x - element.cropRect.x * fullWidth; - const fullY = element.y - element.cropRect.y * fullHeight; + const fullFrame = anchorRotatedFrame({x: element.x, y: element.y, + width: element.width ?? DEFAULT_WIDTH, height: element.height ?? DEFAULT_HEIGHT}, + {x: element.x - element.cropRect.x * fullWidth, y: element.y - element.cropRect.y * fullHeight, + width: fullWidth, height: fullHeight}, element.rotation ?? 0); + const fullX = fullFrame.x, fullY = fullFrame.y; x.value = fullX; y.value = fullY; @@ -295,6 +240,7 @@ export function ImageInsertOverlay({ cropX, cropY, element.cropRect, + element.rotation, element.height, element.width, element.x, @@ -310,16 +256,21 @@ export function ImageInsertOverlay({ y, ]); - const dragGesture = Gesture.Pan() + const dragOriginX = useSharedValue(0); + const dragOriginY = useSharedValue(0); + const dragGesture = Gesture.Pan().maxPointers(1) .enabled(isSelected && !isCropMode) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {dragOriginX.value = touch.absoluteX; dragOriginY.value = touch.absoluteY;} + hasMoved.value = false; runOnJS(selectFromFinger)(); }) .onTouchesMove((_event, stateManager) => { @@ -339,21 +290,21 @@ export function ImageInsertOverlay({ 'worklet'; if (locked || !hasMoved.value) return; x.value = clamp( - savedX.value + evt.translationX / pageScale, + savedX.value + (evt.absoluteX - dragOriginX.value) / (gestureScale?.value ?? pageScale), 0, Math.max(0, pageWidth - width.value), ); - y.value = clamp( - savedY.value + evt.translationY / pageScale, - 0, - Math.max(0, pageHeight - height.value), - ); + // The owner page remains stable when an asset crosses a page boundary. + y.value = savedY.value + (evt.absoluteY - dragOriginY.value) / (gestureScale?.value ?? pageScale); }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; - if (hasMoved.value) { - runOnJS(commitPosition)(x.value, y.value); - } + if (success && hasMoved.value) {runOnJS(commitPosition)(x.value, y.value);} + }) + .onFinalize((_event, success) => { + 'worklet'; + if (!success && hasMoved.value) {x.value = savedX.value; y.value = savedY.value;} + hasMoved.value = false; }); const tapGesture = Gesture.Tap() @@ -362,21 +313,21 @@ export function ImageInsertOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); } }) .onEnd((_evt, success) => { 'worklet'; - if (success) runOnJS(onSelect)(); + if (success) runOnJS(nativeSelect)(); }); - const rotateGesture = Gesture.Pan() + const rotateGesture = Gesture.Pan().maxPointers(1) .enabled(isSelected && !locked && !isCropMode) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } @@ -391,7 +342,7 @@ export function ImageInsertOverlay({ evt.absoluteY - rotateCenterY.value, evt.absoluteX - rotateCenterX.value, ); - runOnJS(onSelect)(); + runOnJS(nativeSelect)(); }) .onUpdate((evt) => { 'worklet'; @@ -403,21 +354,26 @@ export function ImageInsertOverlay({ savedRotation.value + ((currentAngle - rotateStartPointerAngle.value) * 180) / Math.PI; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; - runOnJS(commitRotation)(rotation.value); + if (success) runOnJS(commitRotation)(rotation.value); + else rotation.value = savedRotation.value; }); + const resizeOriginX = useSharedValue(0); + const resizeOriginY = useSharedValue(0); const createResizeGesture = (direction: ResizeHandle) => - Gesture.Pan() + Gesture.Pan().maxPointers(1) .enabled(isSelected && !locked && !isCropMode) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {resizeOriginX.value = touch.absoluteX; resizeOriginY.value = touch.absoluteY;} stateManager.activate(); }) .onStart(() => { @@ -429,26 +385,30 @@ export function ImageInsertOverlay({ }) .onUpdate((evt) => { 'worklet'; - const next = resizeFromCorner( + const delta = localPointerDelta(evt.absoluteX - resizeOriginX.value, + evt.absoluteY - resizeOriginY.value, gestureScale?.value ?? pageScale, rotation.value); + const resized = resizeFromCorner( direction, savedX.value, savedY.value, savedWidth.value, savedHeight.value, - evt.translationX / pageScale, - evt.translationY / pageScale, + delta.x, + delta.y, pageWidth, - pageHeight, preserveAspectRatio, ); + const next = anchorRotatedFrame({x: savedX.value, y: savedY.value, + width: savedWidth.value, height: savedHeight.value}, resized, rotation.value); x.value = next.x; y.value = next.y; width.value = next.width; height.value = next.height; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; - runOnJS(commitSize)(width.value, height.value, x.value, y.value); + if (success) runOnJS(commitSize)(width.value, height.value, x.value, y.value); + else { x.value = savedX.value; y.value = savedY.value; width.value = savedWidth.value; height.value = savedHeight.value; } }); const animatedStyle = useAnimatedStyle(() => ({ @@ -470,6 +430,8 @@ export function ImageInsertOverlay({ return ( {!locked && !isCropMode ? ( - + ) : null} {canCrop ? ( setIsCropMode((value) => !value)} @@ -524,6 +487,7 @@ export function ImageInsertOverlay({ {isCropMode ? ( <> ) : null} onUpdate({ locked: !locked })} + onPress={() => { setIsCropMode(false); onUpdate({ locked: !locked }); }} /> ReturnType; }) { return ( <> {RESIZE_HANDLES.map((handle) => ( - + ))} @@ -711,7 +681,10 @@ function ResizeHandles({ } function CropHandles({ + elementId, + rotation, pageScale, + gestureScale, width, height, x, @@ -730,7 +703,10 @@ function CropHandles({ savedCropHeight, onUpdateCrop, }: { + elementId: string; + rotation: SharedValue; pageScale: number; + gestureScale?: SharedValue; width: SharedValue; height: SharedValue; x: SharedValue; @@ -755,12 +731,16 @@ function CropHandles({ y: number, ) => void; }) { + const nativeCrop = useNativeCallback(onUpdateCrop); + const touchStart = useSharedValue({x: 0, y: 0}); const createCropGesture = (edge: CropHandle) => - Gesture.Pan() + Gesture.Pan().maxPointers(1) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + const touch = event.allTouches[0]; + if (touch) touchStart.value = {x: touch.absoluteX, y: touch.absoluteY}; + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } @@ -782,72 +762,25 @@ function CropHandles({ }) .onUpdate((event) => { 'worklet'; - const fullWidth = savedWidth.value / Math.max(0.001, savedCropWidth.value); - const fullHeight = savedHeight.value / Math.max(0.001, savedCropHeight.value); - const minWidth = Math.min(savedWidth.value, MIN_CROP_SIZE); - const minHeight = Math.min(savedHeight.value, MIN_CROP_SIZE); - let nextX = savedX.value; - let nextY = savedY.value; - let nextWidth = savedWidth.value; - let nextHeight = savedHeight.value; - let nextCropX = savedCropX.value; - let nextCropY = savedCropY.value; - let nextCropWidth = savedCropWidth.value; - let nextCropHeight = savedCropHeight.value; - - if (edge.includes('left')) { - const maxDelta = Math.min( - savedWidth.value - minWidth, - (1 - savedCropX.value) * fullWidth - minWidth, - ); - const dx = Math.max( - -savedCropX.value * fullWidth, - Math.min(maxDelta, event.translationX / pageScale), - ); - nextX = savedX.value + dx; - nextWidth = savedWidth.value - dx; - nextCropX = savedCropX.value + dx / fullWidth; - nextCropWidth = savedCropWidth.value - dx / fullWidth; - } else if (edge.includes('right')) { - const minDelta = minWidth - savedWidth.value; - const maxDelta = (1 - savedCropX.value - savedCropWidth.value) * fullWidth; - const dx = Math.max(minDelta, Math.min(maxDelta, event.translationX / pageScale)); - nextWidth = savedWidth.value + dx; - nextCropWidth = savedCropWidth.value + dx / fullWidth; - } - - if (edge.includes('top')) { - const maxDelta = Math.min( - savedHeight.value - minHeight, - (1 - savedCropY.value) * fullHeight - minHeight, - ); - const dy = Math.max( - -savedCropY.value * fullHeight, - Math.min(maxDelta, event.translationY / pageScale), - ); - nextY = savedY.value + dy; - nextHeight = savedHeight.value - dy; - nextCropY = savedCropY.value + dy / fullHeight; - nextCropHeight = savedCropHeight.value - dy / fullHeight; - } else if (edge.includes('bottom')) { - const minDelta = minHeight - savedHeight.value; - const maxDelta = (1 - savedCropY.value - savedCropHeight.value) * fullHeight; - const dy = Math.max(minDelta, Math.min(maxDelta, event.translationY / pageScale)); - nextHeight = savedHeight.value + dy; - nextCropHeight = savedCropHeight.value + dy / fullHeight; - } - - x.value = nextX; - y.value = nextY; - width.value = nextWidth; - height.value = nextHeight; - cropX.value = Math.max(0, Math.min(1, nextCropX)); - cropY.value = Math.max(0, Math.min(1, nextCropY)); - cropWidth.value = Math.max(0.02, Math.min(1 - cropX.value, nextCropWidth)); - cropHeight.value = Math.max(0.02, Math.min(1 - cropY.value, nextCropHeight)); + const delta = localPointerDelta(event.absoluteX - touchStart.value.x, + event.absoluteY - touchStart.value.y, gestureScale?.value ?? pageScale, rotation.value); + const next = cropFromEdge({x: savedX.value, y: savedY.value, + width: savedWidth.value, height: savedHeight.value}, + {x: savedCropX.value, y: savedCropY.value, width: savedCropWidth.value, height: savedCropHeight.value}, + edge, delta.x, delta.y, rotation.value); + x.value = next.frame.x; y.value = next.frame.y; + width.value = next.frame.width; height.value = next.frame.height; + cropX.value = next.crop.x; cropY.value = next.crop.y; + cropWidth.value = next.crop.width; cropHeight.value = next.crop.height; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; + if (!success) { + x.value = savedX.value; y.value = savedY.value; width.value = savedWidth.value; height.value = savedHeight.value; + cropX.value = savedCropX.value; cropY.value = savedCropY.value; + cropWidth.value = savedCropWidth.value; cropHeight.value = savedCropHeight.value; + return; + } const nextCropRect = { x: cropX.value, y: cropY.value, @@ -859,7 +792,7 @@ function CropHandles({ nextCropRect.y <= 0.001 && nextCropRect.width >= 0.999 && nextCropRect.height >= 0.999; - runOnJS(onUpdateCrop)( + runOnJS(nativeCrop)( isFullImage ? undefined : nextCropRect, width.value, height.value, @@ -873,7 +806,7 @@ function CropHandles({ {CROP_HANDLES.map((handle) => ( - ['name']; color: string; onPress: () => void; }) { return ( { + let min = 0; + let max = 0; + for (const page of pages) { + for (const element of page.insertedElements ?? []) { + min = Math.min(min, element.zIndex ?? 0); + max = Math.max(max, element.zIndex ?? 0); + } + } + return { min, max }; + }, [pages]); const transformRef = useRef(store.getSnapshot()); const [hasTransform, setHasTransform] = useState(() => transformRef.current !== null); + const gestureScale = useSharedValue(store.getSnapshot()?.scale ?? 1); useEffect(() => { const unsub = store.subscribe(() => { const next = store.getSnapshot(); if (!next) return; transformRef.current = next; + gestureScale.value = next.scale; setHasTransform(true); }); return unsub; @@ -151,7 +167,7 @@ export function OverlayLayer({ {captureTaps ? ( ) : null} @@ -161,19 +177,7 @@ export function OverlayLayer({ (a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0), ); return ( - + {elements.map((el) => { const canRenderElement = el.type === 'image' || @@ -186,35 +190,36 @@ export function OverlayLayer({ return canRenderElement ? ( onSelectionChange({ type: 'image', pageIndex, id: el.id }) } - onUpdate={(patch) => onUpdateInsertedElement(pageIndex, el.id, patch)} + onUpdate={(patch) => onUpdateInsertedElement(pageIndex, el.id, patchForOwner(patch, pageIndex, pageHeight))} onUpdateCrop={(cropRect, width, height, x, y) => onUpdateInsertedElement(pageIndex, el.id, { cropRect, width, height, x, - y, + y: y === undefined ? undefined : y - pageIndex * pageHeight, }) } onDuplicate={() => onDuplicateInsertedElement(pageIndex, el.id)} onBringToFront={() => { const nextZIndex = - Math.max(0, ...elements.map((candidate) => candidate.zIndex ?? 0)) + 1; + imageLayerBounds.max + 1; onUpdateInsertedElement(pageIndex, el.id, { zIndex: nextZIndex }); }} onSendToBack={() => { const nextZIndex = - Math.min(0, ...elements.map((candidate) => candidate.zIndex ?? 0)) - 1; + imageLayerBounds.min - 1; onUpdateInsertedElement(pageIndex, el.id, { zIndex: nextZIndex }); }} onRemove={() => { @@ -232,12 +237,13 @@ export function OverlayLayer({ return ( @@ -252,7 +258,7 @@ export function OverlayLayer({ }) } onCommit={(patch) => { - onUpdateTextBox(pageIndex, tb.id, patch); + onUpdateTextBox(pageIndex, tb.id, patchForOwner(patch, pageIndex, pageHeight)); if (patch.isEditing === false) { onSelectionChange({ type: 'text', pageIndex, id: tb.id }); } @@ -264,16 +270,9 @@ export function OverlayLayer({ /> ); })} - + ); })} ); } - -const styles = StyleSheet.create({ - pageHost: { - position: 'absolute', - overflow: 'hidden', - }, -}); diff --git a/src/components/editor/PageSidebar.tsx b/src/components/editor/PageSidebar.tsx index 70b01a2..a37903b 100644 --- a/src/components/editor/PageSidebar.tsx +++ b/src/components/editor/PageSidebar.tsx @@ -3,7 +3,8 @@ import { Image, Keyboard, Pressable, - ScrollView, + FlatList, + useWindowDimensions, StyleSheet, Text, TextInput, @@ -45,7 +46,8 @@ export function PageSidebar({ onJumpToPage, }: PageSidebarProps) { const theme = useTheme(); - const scrollRef = useRef(null); + const scrollRef = useRef | null>(null); + const { height: windowHeight } = useWindowDimensions(); const pageCount = Math.max(1, pages.length); const [draftPage, setDraftPage] = useState(String(currentPage + 1)); @@ -54,12 +56,13 @@ export function PageSidebar({ }, [currentPage]); useEffect(() => { - if (!visible) return; - const y = Math.max(0, currentPage * (THUMBNAIL_HEIGHT + spacing.lg) - spacing.lg); - requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ y, animated: true }); + if (!visible || pages.length === 0) return; + const index = Math.max(0, Math.min(pages.length - 1, currentPage)); + const frame = requestAnimationFrame(() => { + scrollRef.current?.scrollToIndex({ index, animated: false, viewOffset: spacing.md }); }); - }, [currentPage, visible]); + return () => cancelAnimationFrame(frame); + }, [currentPage, pages.length, visible]); const jumpToPageNumber = useCallback( (pageNumber: number) => { @@ -139,12 +142,22 @@ export function PageSidebar({ - page.id} + initialScrollIndex={pages.length ? Math.max(0, Math.min(pages.length - 1, currentPage)) : undefined} + initialNumToRender={Math.max(1, Math.ceil(windowHeight / (THUMBNAIL_HEIGHT + spacing.md)))} + maxToRenderPerBatch={2} + windowSize={3} + updateCellsBatchingPeriod={32} + getItemLayout={(_, index) => ({index, length: THUMBNAIL_HEIGHT, + offset: spacing.md + index * (THUMBNAIL_HEIGHT + spacing.md)})} + ItemSeparatorComponent={PageSeparator} showsVerticalScrollIndicator={false} contentContainerStyle={styles.thumbList} - > - {pages.map((page, index) => { + renderItem={({ item: page, index }) => { const active = index === currentPage; return ( ); - })} - + }} + /> ); } +function PageSeparator() { + return ; +} + const styles = StyleSheet.create({ sidebar: { borderLeftWidth: StyleSheet.hairlineWidth, @@ -264,7 +281,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingBottom: spacing.xl, paddingTop: spacing.md, - rowGap: spacing.md, }, thumbButton: { alignItems: 'center', diff --git a/src/components/editor/README.md b/src/components/editor/README.md new file mode 100644 index 0000000..0963e5d --- /dev/null +++ b/src/components/editor/README.md @@ -0,0 +1,42 @@ +# Note editor + +OverlayLayer renders each image/text box once in document coordinates. An asset +keeps its original owner page even when dragged across a boundary; callbacks +convert positions back to that owner's local coordinates. Export projects the +same asset onto every page it overlaps. + +Image and text dragging measure movement from the initial absolute touch point, +not the gesture recognizer's later activation point. Their shared zoom value is +updated from the viewport store without rerendering every overlay on each frame. +Cancelled drags restore the starting position. Resize/crop/rotation paths have +separate handlers and are covered by the native interaction audit. + +The history directory serializes user overlay changes with native ink undo/redo. +The note screen maintains synchronous overlay refs for saves and waits for history +before persistence/export. Image picker storage owns durable resource files. + +`overlayGeometry.ts` keeps asset resize coordinates relative to the owning page. +Negative top positions and heights spanning neighboring pages are valid; resize +must not clamp them into one page. Horizontal bounds and minimum sizes remain. +Image and text drags also preserve owner-relative coordinates across page breaks; +text resizing uses the same unbounded vertical-coordinate rule. + +Asset pan gestures accept one finger; multi-touch is left to the viewport pinch. +Canceled resize/crop/rotation gestures restore their starting geometry instead of +committing a partial edit. Native zoom tests assert achieved visual scale, not +merely that XCTest issued a pinch. + +Native cross-page verification uses the same drag/resize/undo/restart/export checks for images and text, including an explicit achieved zoom assertion. Text does not enlarge during gestures, preserving exact pointer-to-box geometry. + +Rotated image resize and crop deltas are converted from absolute pointer motion into image-local axes using the current viewport scale. Frame anchoring preserves the opposite rotated edge. Crop limits keep normalized source rectangles and displayed dimensions consistent, including the minimum visible crop. + +Native notebook load failures use the same recovery screen as unreadable body files. Both deferred and already-mounted loads catch rejection, disable autosave, and reset canvas readiness before retry. The invalid-native-payload fixture verifies retry/relaunch keeps the original body instead of replacing it with a blank notebook. + +Run the native audit against an owned simulator with the release-built app +installed. Run `python3 scripts/seed-native-audit.py SIMULATOR_UUID` before +each suite to reset fixture geometry; the image/text tests persist their edits. +Use the `OpenNotesIntegrity` Xcode scheme for the UI cases, then run +`python3 scripts/verify-native-audit.py SIMULATOR_UUID` to verify the dense +notebook's stroke count and preservation of the invalid drawing bytes. +Undo/Redo stay disabled until native document loading succeeds, and remain +available during autosaves. diff --git a/src/components/editor/TextBoxOverlay.tsx b/src/components/editor/TextBoxOverlay.tsx index 887a696..faa9bc2 100644 --- a/src/components/editor/TextBoxOverlay.tsx +++ b/src/components/editor/TextBoxOverlay.tsx @@ -1,3 +1,4 @@ +import {useNativeCallback} from '../../hooks/native-callback'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Platform, @@ -18,6 +19,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue, + type SharedValue, } from 'react-native-reanimated'; import type { InkTextBox } from '@mathnotes/mobile-ink'; import { useTheme } from '../../hooks/useTheme'; @@ -43,6 +45,7 @@ type ResizeHandle = export interface TextBoxOverlayProps { textBox: InkTextBox; pageScale: number; + gestureScale?: SharedValue; screenLeft: number; screenTop: number; pageWidth: number; @@ -95,9 +98,7 @@ function resizeFromHandle( } nextX = clamp(nextX, 0, Math.max(0, pageWidth - nextWidth)); - nextY = clamp(nextY, 0, Math.max(0, pageHeight - nextHeight)); nextWidth = Math.min(nextWidth, pageWidth - nextX); - nextHeight = Math.min(nextHeight, pageHeight - nextY); return { x: nextX, y: nextY, width: nextWidth, height: nextHeight }; } @@ -105,6 +106,7 @@ function resizeFromHandle( export function TextBoxOverlay({ textBox, pageScale, + gestureScale, screenLeft, screenTop, pageWidth, @@ -176,7 +178,7 @@ export function TextBoxOverlay({ y, ]); - const commitLayout = useCallback( + const commitLayout = useNativeCallback( (nextX: number, nextY: number, nextWidth: number, nextHeight: number) => { onCommit({ x: nextX, @@ -185,15 +187,14 @@ export function TextBoxOverlay({ height: nextHeight, }); }, - [onCommit], ); - const selectFromFinger = useCallback(() => { + const selectFromFinger = useNativeCallback(() => { if (!isSelectedRef.current) { suppressEditUntilRef.current = Date.now() + 250; } onSelect(); - }, [onSelect]); + }); const handleFingerPointerDown = useCallback((event: any) => { const pointerType = event.nativeEvent.pointerType; @@ -202,7 +203,7 @@ export function TextBoxOverlay({ selectFromFinger(); }, [isEditing, selectFromFinger]); - const handleTap = useCallback(() => { + const handleTap = useNativeCallback(() => { if (isSelectedRef.current) { if (Date.now() < suppressEditUntilRef.current) return; onStartEdit(); @@ -210,7 +211,7 @@ export function TextBoxOverlay({ suppressEditUntilRef.current = Date.now() + 250; onSelect(); } - }, [onSelect, onStartEdit]); + }); const handleContentChange = useCallback( (content: string) => { @@ -220,16 +221,21 @@ export function TextBoxOverlay({ [onCommit], ); - const dragGesture = Gesture.Pan() + const dragOriginX = useSharedValue(0); + const dragOriginY = useSharedValue(0); + const dragGesture = Gesture.Pan().maxPointers(1) .enabled(isSelected && !isEditing) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {dragOriginX.value = touch.absoluteX; dragOriginY.value = touch.absoluteY;} + isDragging.value = false; runOnJS(selectFromFinger)(); }) .onTouchesMove((_event, stateManager) => { @@ -246,20 +252,21 @@ export function TextBoxOverlay({ .onUpdate((evt) => { 'worklet'; x.value = clamp( - savedX.value + evt.translationX / pageScale, + savedX.value + (evt.absoluteX - dragOriginX.value) / (gestureScale?.value ?? pageScale), 0, Math.max(0, pageWidth - width.value), ); - y.value = clamp( - savedY.value + evt.translationY / pageScale, - 0, - Math.max(0, pageHeight - height.value), - ); + // The owner page remains stable when an asset crosses a page boundary. + y.value = savedY.value + (evt.absoluteY - dragOriginY.value) / (gestureScale?.value ?? pageScale); + }) + .onEnd((_event, success) => { + 'worklet'; + if (success && isDragging.value) {runOnJS(commitLayout)(x.value, y.value, width.value, height.value);} }) - .onEnd(() => { + .onFinalize((_event, success) => { 'worklet'; + if (!success && isDragging.value) {x.value = savedX.value; y.value = savedY.value;} isDragging.value = false; - runOnJS(commitLayout)(x.value, y.value, width.value, height.value); }); const tapGesture = Gesture.Tap() @@ -268,7 +275,7 @@ export function TextBoxOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); } }) @@ -277,16 +284,20 @@ export function TextBoxOverlay({ if (success) runOnJS(handleTap)(); }); + const resizeOriginX = useSharedValue(0); + const resizeOriginY = useSharedValue(0); const createResizeGesture = (handle: ResizeHandle) => - Gesture.Pan() + Gesture.Pan().maxPointers(1) .enabled(isSelected && !isEditing) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {resizeOriginX.value = touch.absoluteX; resizeOriginY.value = touch.absoluteY;} stateManager.activate(); }) .onStart(() => { @@ -305,8 +316,8 @@ export function TextBoxOverlay({ savedY.value, savedWidth.value, savedHeight.value, - evt.translationX / pageScale, - evt.translationY / pageScale, + (evt.absoluteX - resizeOriginX.value) / (gestureScale?.value ?? pageScale), + (evt.absoluteY - resizeOriginY.value) / (gestureScale?.value ?? pageScale), pageWidth, pageHeight, ); @@ -315,10 +326,11 @@ export function TextBoxOverlay({ width.value = next.width; height.value = next.height; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; isResizing.value = false; - runOnJS(commitLayout)(x.value, y.value, width.value, height.value); + if (success) runOnJS(commitLayout)(x.value, y.value, width.value, height.value); + else { x.value = savedX.value; y.value = savedY.value; width.value = savedWidth.value; height.value = savedHeight.value; } }); const animatedStyle = useAnimatedStyle(() => ({ @@ -332,7 +344,6 @@ export function TextBoxOverlay({ transform: [ { translateX: x.value }, { translateY: y.value }, - { scale: isDragging.value || isResizing.value ? 1.02 : 1 }, ], })); @@ -343,6 +354,8 @@ export function TextBoxOverlay({ return ( - + ; unset?: string[] }; +type Patch = { format: 'opennotes-overlay-1'; changes: Change[] }; +const collections: Collection[] = ['insertedElements', 'textBoxes']; +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const items = (page: NotebookPage, key: Collection) => (page[key] ?? []) as unknown as Item[]; + +/** History records only changed overlay fields; ink and derived metadata stay current. */ +export function overlayPatches(before: NotebookPage[], after: NotebookPage[]): {before: string; after: string} | null { + if (before.length !== after.length || before.some((page, i) => page.id !== after[i]?.id)) { + throw new Error('Overlay edits cannot change notebook pages'); + } + const backward: Change[] = [], forward: Change[] = []; + before.forEach((page, pageIndex) => { + for (const collection of collections) { + const oldItems = items(page, collection), newItems = items(after[pageIndex], collection); + if (oldItems === newItems) continue; + if (new Set(oldItems.map(item => item.id)).size !== oldItems.length || + new Set(newItems.map(item => item.id)).size !== newItems.length) { + throw new Error('Overlay IDs must be unique within a page'); + } + const oldMap = new Map(oldItems.map((item, index) => [item.id, {item, index}])); + const newMap = new Map(newItems.map((item, index) => [item.id, {item, index}])); + const retainedBefore = oldItems.filter(item => newMap.has(item.id)).map(item => item.id); + const retainedAfter = newItems.filter(item => oldMap.has(item.id)).map(item => item.id); + if (!same(retainedBefore, retainedAfter)) throw new Error('Overlay ordering must use zIndex'); + for (const id of new Set([...oldMap.keys(), ...newMap.keys()])) { + const old = oldMap.get(id), next = newMap.get(id); + if (old?.item === next?.item) continue; + const common = {pageId: page.id, collection, id, index: old?.index ?? next!.index}; + if (!old || !next) { + backward.push({...common, replace: old?.item ?? null}); + forward.push({...common, replace: next?.item ?? null}); + continue; + } + const toBefore: Change = {...common, set: {}, unset: []}; + const toAfter: Change = {...common, set: {}, unset: []}; + for (const key of new Set([...Object.keys(old.item), ...Object.keys(next.item)])) { + if (key === 'updatedAt' || key === 'id' || same(old.item[key], next.item[key])) continue; + if (forbidden.has(key)) throw new Error('Invalid overlay property'); + if (old.item[key] === undefined) toBefore.unset!.push(key); + else toBefore.set![key] = old.item[key]; + if (next.item[key] === undefined) toAfter.unset!.push(key); + else toAfter.set![key] = next.item[key]; + } + if (Object.keys(toAfter.set!).length || toAfter.unset!.length) { + backward.push(toBefore); forward.push(toAfter); + } + } + } + }); + return forward.length ? { + before: JSON.stringify({format: 'opennotes-overlay-1', changes: backward}), + after: JSON.stringify({format: 'opennotes-overlay-1', changes: forward}), + } : null; +} + +export function applyOverlayPatch(pages: NotebookPage[], payload: string): NotebookPage[] { + const patch: Patch = JSON.parse(payload); + if (patch?.format !== 'opennotes-overlay-1' || !Array.isArray(patch.changes)) { + throw new Error('Unrecognized overlay history'); + } + let next = pages; + for (const change of patch.changes) { + if (!change || !collections.includes(change.collection) || typeof change.id !== 'string' || + typeof change.pageId !== 'string' || !Number.isSafeInteger(change.index) || change.index < 0) { + throw new Error('Invalid overlay history entry'); + } + const pageIndex = next.findIndex(page => page.id === change.pageId); + if (pageIndex < 0) throw new Error('Overlay history refers to a removed page'); + const existing = items(next[pageIndex], change.collection); + const index = existing.findIndex(item => item.id === change.id); + const updated = [...existing]; + if ('replace' in change) { + if (change.replace === null) { + if (index >= 0) updated.splice(index, 1); + } else { + if (!isRecord(change.replace) || change.replace.id !== change.id || + Object.keys(change.replace).some(key => forbidden.has(key))) throw new Error('Invalid overlay replacement'); + const value = {...change.replace}; + // A restored in-flight figure must restart compilation, not wait on a cancelled job. + if (value.type === 'latex-figure' && value.renderStatus === 'rendering') value.renderStatus = 'pending'; + if (index >= 0) updated[index] = value; + else updated.splice(Math.min(change.index, updated.length), 0, value); + } + } else { + if (index < 0 || !isRecord(change.set) || !Array.isArray(change.unset)) throw new Error('Overlay history target is missing'); + const keys = [...Object.keys(change.set), ...change.unset]; + if (keys.some(key => typeof key !== 'string' || forbidden.has(key) || key === 'id')) throw new Error('Invalid overlay property'); + const value = {...updated[index], ...change.set}; + for (const key of change.unset) delete value[key]; + updated[index] = value; + } + next = next.map((page, i) => i === pageIndex ? {...page, [change.collection]: updated} as NotebookPage : page); + } + return next; +} diff --git a/src/components/editor/history/transactions.ts b/src/components/editor/history/transactions.ts new file mode 100644 index 0000000..6a8a08c --- /dev/null +++ b/src/components/editor/history/transactions.ts @@ -0,0 +1,62 @@ +import type {NotebookPage} from '@mathnotes/mobile-ink'; +import {applyOverlayPatch, overlayPatches} from './patch.ts'; + +type Update = NotebookPage[] | ((pages: NotebookPage[]) => NotebookPage[]); +type Options = { + read: () => NotebookPage[]; + write: (pages: NotebookPage[]) => void; + onCommitFailure?: (error: Error) => void; + prepare?: (before: NotebookPage[], after: NotebookPage[]) => Promise; + record: (before: string, after: string) => Promise; +}; + +/** Serializes overlay commits with undo/redo and persistence barriers. */ +export function createOverlayTransactions({read, write, record, prepare, onCommitFailure}: Options) { + let tail: Promise = Promise.resolve(); + let pending = 0; + let disposed = false; + + function enqueue(operation: () => Promise): Promise { + if (disposed) return Promise.reject(new Error('Notebook history has closed')); + pending += 1; + const result = tail.then(async () => { + if (disposed) throw new Error('Notebook history has closed'); + return operation(); + }); + // The caller receives failures; later valid edits must remain usable. + tail = result.then(() => undefined, () => undefined).finally(() => {pending -= 1;}); + return result; + } + + return { + commit(update: Update): Promise { + return enqueue(async () => { + const before = read(); + // Evaluate outside React's state updater: StrictMode must not record twice. + let after = typeof update === 'function' ? update(before) : update; + if (prepare) after = await prepare(before, after); + if (disposed) throw new Error('Notebook closed while preparing overlay edit'); + const patches = overlayPatches(before, after); + if (!patches) return; + if (!await record(patches.before, patches.after)) { + throw new Error('Ink engine could not record the overlay edit'); + } + if (disposed) throw new Error('Notebook closed before overlay commit'); + // Derived image/render metadata and newly captured ink may have arrived + // while crossing the native bridge. Apply just the user's changed fields. + try {write(applyOverlayPatch(read(), patches.after));} catch (error) { + onCommitFailure?.(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }); + }, + run: enqueue, + async flush(): Promise { + // Include operations enqueued while a bridge call was in flight. + let observed: Promise; + do {observed = tail; await observed;} while (observed !== tail); + }, + hasPending: () => pending > 0, + dispose() {disposed = true;}, + }; +} diff --git a/src/components/editor/history/useOverlayHistory.ts b/src/components/editor/history/useOverlayHistory.ts new file mode 100644 index 0000000..70ceecc --- /dev/null +++ b/src/components/editor/history/useOverlayHistory.ts @@ -0,0 +1,117 @@ +import {useCallback, useEffect, useRef} from 'react'; +import {Alert} from 'react-native'; +import {t} from '../../../i18n'; +import type {NotebookPage, ContinuousDocumentCanvasRef} from '@mathnotes/mobile-ink'; +type DrawingEvent = {nativeEvent: {historyAction?: 'undo' | 'redo'; externalEdit?: {payload: string}}}; +import {applyOverlayPatch} from './patch'; +import {createOverlayTransactions} from './transactions'; + +type Action = 'undo' | 'redo'; +type Inputs = { + fileId?: string | null; + readPages: () => NotebookPage[]; + canvasRef: React.RefObject; + writePages: (pages: NotebookPage[]) => void; + onChange: () => void; +}; +type Ack = {action: Action; resolve: () => void; reject: (error: Error) => void; timer: ReturnType}; + +export function useOverlayHistory(options: Inputs) { + const latest = useRef(options); + latest.current = options; + const queue = useRef | null>(null); + const ack = useRef(null); + const fault = useRef(null); + const write = useCallback((pages: NotebookPage[]) => { + latest.current.writePages(pages); + latest.current.onChange(); + }, []); + useEffect(() => { + fault.current = null; + const current = createOverlayTransactions({ + read: () => latest.current.readPages(), + write, + onCommitFailure: error => {fault.current = error;}, + record: async (before, after) => { + if (fault.current) throw fault.current; + const canvas = latest.current.canvasRef.current; + if (!canvas) throw new Error('Ink canvas is unavailable'); + return canvas.recordExternalEdit(before, after); + }, + }); + queue.current = current; + return () => { + current.dispose(); + if (ack.current) { + clearTimeout(ack.current.timer); + ack.current.reject(new Error('Notebook closed during history operation')); + ack.current = null; + } + if (queue.current === current) queue.current = null; + }; + }, [options.fileId, write]); + + const report = useCallback((error: unknown) => { + console.error('[OverlayHistory] Edit could not complete', error); + Alert.alert(t.common.pleaseTryAgain, t.common.pleaseTryAgainLater); + }, []); + const setOverlayPages = useCallback>>(update => { + if (!queue.current) {report(new Error('Notebook history is unavailable')); return;} + void queue.current.commit(update).catch(report); + }, [report]); + const flushOverlayHistory = useCallback(async () => { + await queue.current?.flush(); + if (fault.current) throw fault.current; + }, []); + const runAfterOverlayHistory = useCallback((operation: () => Promise): Promise => { + if (!queue.current) return Promise.reject(new Error('Notebook history is unavailable')); + return queue.current.run(async () => { + if (fault.current) throw fault.current; + return operation(); + }); + }, []); + const hasPendingOverlayHistory = useCallback(() => Boolean(queue.current?.hasPending()), []); + const handleHistoryDrawingChange = useCallback<(event?: DrawingEvent) => void>(event => { + const data = event?.nativeEvent; + try { + if (data?.externalEdit) write(applyOverlayPatch(latest.current.readPages(), data.externalEdit.payload)); + const pending = ack.current; + if (pending && data?.historyAction === pending.action) { + clearTimeout(pending.timer); + pending.resolve(); + ack.current = null; + } + } catch (error) { + fault.current = error instanceof Error ? error : new Error(String(error)); + if (ack.current) { + clearTimeout(ack.current.timer); + ack.current.reject(fault.current); + ack.current = null; + } else report(error); + } + }, [report, write]); + const runHistory = useCallback((action: Action) => { + if (!queue.current) {report(new Error('Notebook history is unavailable')); return;} + void queue.current.run(async () => { + if (fault.current) throw fault.current; + const canvas = latest.current.canvasRef.current; + if (!canvas) throw new Error('Ink canvas is unavailable'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + fault.current = new Error('Native history acknowledgement timed out'); + ack.current = null; + reject(fault.current); + }, 10000); + ack.current = {action, resolve, reject, timer}; + try {canvas[action]();} catch (error) { + clearTimeout(timer); + ack.current = null; + reject(error); + } + }); + }).catch(report); + }, [report]); + return {setOverlayPages, flushOverlayHistory, hasPendingOverlayHistory, runAfterOverlayHistory, + handleHistoryDrawingChange, undoHistory: useCallback(() => runHistory('undo'), [runHistory]), + redoHistory: useCallback(() => runHistory('redo'), [runHistory])}; +} diff --git a/src/components/editor/overlayGeometry.ts b/src/components/editor/overlayGeometry.ts new file mode 100644 index 0000000..85e0b59 --- /dev/null +++ b/src/components/editor/overlayGeometry.ts @@ -0,0 +1,114 @@ +export type ResizeHandle = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +const DEFAULT_MIN_SIZE = 50; +function clamp(value: number, min: number, max: number): number { + 'worklet'; return Math.max(min, Math.min(max, value)); +} + +// Vertical positions are owner-relative and may span neighboring pages. +export function resizeFromCorner( + direction: ResizeHandle, + baseX: number, + baseY: number, + baseWidth: number, + baseHeight: number, + dx: number, + dy: number, + pageWidth: number, + preserveAspectRatio: boolean, +) { + 'worklet'; + const aspectRatio = baseHeight > 0 ? baseWidth / baseHeight : 1; + let nextX = baseX; + let nextY = baseY; + let nextWidth = baseWidth; + let nextHeight = baseHeight; + + if (preserveAspectRatio) { + const rawWidth = direction.includes('left') ? baseWidth - dx : baseWidth + dx; + const rawHeight = direction.includes('top') ? baseHeight - dy : baseHeight + dy; + const widthScale = rawWidth / baseWidth; + const heightScale = rawHeight / baseHeight; + const minScale = Math.max(DEFAULT_MIN_SIZE / baseWidth, DEFAULT_MIN_SIZE / baseHeight); + const uniformScale = Math.max(minScale, Math.max(widthScale, heightScale)); + + nextWidth = Math.min(pageWidth, baseWidth * uniformScale); + nextHeight = nextWidth / aspectRatio; + + if (direction.includes('left')) nextX = baseX + (baseWidth - nextWidth); + if (direction.includes('top')) nextY = baseY + (baseHeight - nextHeight); + } else { + if (direction.includes('right')) { + nextWidth = Math.max(DEFAULT_MIN_SIZE, baseWidth + dx); + } + if (direction.includes('left')) { + const widthDelta = Math.min(dx, baseWidth - DEFAULT_MIN_SIZE); + nextWidth = baseWidth - widthDelta; + nextX = baseX + widthDelta; + } + if (direction.includes('bottom')) { + nextHeight = Math.max(DEFAULT_MIN_SIZE, baseHeight + dy); + } + if (direction.includes('top')) { + const heightDelta = Math.min(dy, baseHeight - DEFAULT_MIN_SIZE); + nextHeight = baseHeight - heightDelta; + nextY = baseY + heightDelta; + } + } + + nextX = clamp(nextX, 0, Math.max(0, pageWidth - nextWidth)); + nextWidth = Math.min(nextWidth, pageWidth - nextX); + + return { x: nextX, y: nextY, width: nextWidth, height: nextHeight }; +} + + +export type AssetFrame = {x: number; y: number; width: number; height: number}; +export type CropRect = AssetFrame; +export type CropHandle = ResizeHandle | 'left' | 'right' | 'top' | 'bottom'; + +export function localPointerDelta(dx: number, dy: number, scale: number, rotation: number) { + 'worklet'; + const angle = rotation * Math.PI / 180; + return {x: (dx * Math.cos(angle) + dy * Math.sin(angle)) / scale, + y: (-dx * Math.sin(angle) + dy * Math.cos(angle)) / scale}; +} + +/** Convert a local-axis resize to a center-rotated frame, fixing the opposite edge. */ +export function anchorRotatedFrame(before: AssetFrame, next: AssetFrame, rotation: number): AssetFrame { + 'worklet'; + const angle = rotation * Math.PI / 180; + const dx = next.x - before.x + (next.width - before.width) / 2; + const dy = next.y - before.y + (next.height - before.height) / 2; + return {...next, + x: before.x + before.width / 2 + dx * Math.cos(angle) - dy * Math.sin(angle) - next.width / 2, + y: before.y + before.height / 2 + dx * Math.sin(angle) + dy * Math.cos(angle) - next.height / 2}; +} + +/** Deltas are local image-axis units; normalized crop and visible size stay in sync. */ +export function cropFromEdge(before: AssetFrame, crop: CropRect, edge: CropHandle, + dx: number, dy: number, rotation: number) { + 'worklet'; + const fullWidth = before.width / Math.max(0.001, crop.width); + const fullHeight = before.height / Math.max(0.001, crop.height); + const minWidth = Math.min(before.width, Math.max(36, fullWidth * 0.02)); + const minHeight = Math.min(before.height, Math.max(36, fullHeight * 0.02)); + const next = {...before}; + const nextCrop = {...crop}; + if (edge.includes('left')) { + const delta = clamp(dx, -crop.x * fullWidth, before.width - minWidth); + next.x += delta; next.width -= delta; + nextCrop.x += delta / fullWidth; nextCrop.width -= delta / fullWidth; + } else if (edge.includes('right')) { + const delta = clamp(dx, minWidth - before.width, (1 - crop.x - crop.width) * fullWidth); + next.width += delta; nextCrop.width += delta / fullWidth; + } + if (edge.includes('top')) { + const delta = clamp(dy, -crop.y * fullHeight, before.height - minHeight); + next.y += delta; next.height -= delta; + nextCrop.y += delta / fullHeight; nextCrop.height -= delta / fullHeight; + } else if (edge.includes('bottom')) { + const delta = clamp(dy, minHeight - before.height, (1 - crop.y - crop.height) * fullHeight); + next.height += delta; nextCrop.height += delta / fullHeight; + } + return {frame: anchorRotatedFrame(before, next, rotation), crop: nextCrop}; +} diff --git a/src/components/library/NoteCard.tsx b/src/components/library/NoteCard.tsx index c9acb9c..dd44e4a 100644 --- a/src/components/library/NoteCard.tsx +++ b/src/components/library/NoteCard.tsx @@ -20,6 +20,7 @@ export function NoteCard({ note, onPress, onLongPress, onMenuPress }: NoteCardPr const tileWidth = useLibraryTileWidth(); return ( Promise; + onStatus: (status: AutosaveStatus) => void; + isEnabled: () => boolean; + debounceMs: number; + onError?: (error: unknown) => void; + /** True when newer edits invalidated the save; their own schedule retries it. */ + isSuperseded?: (error: unknown) => boolean; +} + +/** + * Debounced saves that never overlap. A save captures the document first and + * writes it last, so two concurrent saves could land out of order and let an + * older capture overwrite newer ink on disk. + */ +export function createAutosaveQueue({ save, onStatus, isEnabled, debounceMs, onError, isSuperseded }: AutosaveQueueOptions) { + let timer: ReturnType | null = null; + let inFlight: Promise | null = null; + let dirty = false; + + const clearTimer = () => { + if (timer) clearTimeout(timer); + timer = null; + }; + + const runSave = async (): Promise => { + if (!isEnabled()) return true; + // Cleared before the save so changes made while it runs mark dirty again. + dirty = false; + onStatus('saving'); + try { + await save(); + onStatus(dirty ? 'pending' : 'saved'); + return true; + } catch (error) { + dirty = true; + if (isSuperseded?.(error)) { + onStatus('pending'); + return false; + } + onError?.(error); + onStatus('error'); + return false; + } + }; + + const start = (): Promise => { + const previous = inFlight; + const promise = (async () => { + if (previous) await previous; + if (!dirty) return true; + return runSave(); + })(); + inFlight = promise; + void promise.finally(() => { + if (inFlight === promise) inFlight = null; + }); + return promise; + }; + + return { + schedule() { + if (!isEnabled()) return; + dirty = true; + onStatus('pending'); + clearTimer(); + timer = setTimeout(() => { + timer = null; + void start(); + }, debounceMs); + }, + flushNow(): Promise { + clearTimer(); + return start(); + }, + cancelPending() { + clearTimer(); + onStatus('idle'); + }, + dispose() { + clearTimer(); + }, + }; +} diff --git a/src/hooks/native-callback/README.md b/src/hooks/native-callback/README.md new file mode 100644 index 0000000..0b2f1e7 --- /dev/null +++ b/src/hooks/native-callback/README.md @@ -0,0 +1,10 @@ +# Native callback lifetime + +Callbacks handed to `runOnJS` may remain rooted by the animation runtime after an +editor unmounts. A render closure can indirectly own the entire notebook through +its parent callbacks. `useNativeCallback` returns a stable function created +outside that render scope. A layout effect replaces its current consumer and +clears it on unmount. Late animation events therefore cannot edit a closed note +or retain its contents. This does not suppress callback errors. + +Image and text overlays use this boundary for select, move, resize, rotate, and crop events. diff --git a/src/hooks/native-callback/index.ts b/src/hooks/native-callback/index.ts new file mode 100644 index 0000000..522518e --- /dev/null +++ b/src/hooks/native-callback/index.ts @@ -0,0 +1,20 @@ +import {useLayoutEffect, useMemo} from 'react'; + +/** Only this small cell is retained by the animation runtime, never a render scope. */ +export function createNativeCallback() { + let current: ((...args: Args) => void) | null = null; + return { + update(next: ((...args: Args) => void) | null) { current = next; }, + invoke: (...args: Args) => { current?.(...args); }, + }; +} + +/** Stable runOnJS target; queued calls after unmount are deliberately ignored. */ +export function useNativeCallback(callback: (...args: Args) => void) { + const bridge = useMemo(createNativeCallback, []); + useLayoutEffect(() => { + bridge.update(callback); + return () => bridge.update(null); + }, [bridge, callback]); + return bridge.invoke; +} diff --git a/src/hooks/useAutosave.ts b/src/hooks/useAutosave.ts index 89efe70..433c08c 100644 --- a/src/hooks/useAutosave.ts +++ b/src/hooks/useAutosave.ts @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AppState } from 'react-native'; +import { isDocumentCaptureSuperseded } from '@mathnotes/mobile-ink'; +import { createAutosaveQueue, type AutosaveStatus } from './autosaveQueue'; -const DEFAULT_DEBOUNCE_MS = 350; +export type { AutosaveStatus } from './autosaveQueue'; -export type AutosaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; +const DEFAULT_DEBOUNCE_MS = 350; export interface UseAutosaveOptions { onSave: () => Promise; @@ -18,79 +20,21 @@ export function useAutosave({ debounceMs = DEFAULT_DEBOUNCE_MS, enabled = true, }: UseAutosaveOptions) { - const timerRef = useRef | null>(null); - const inFlightRef = useRef | null>(null); - const dirtyRef = useRef(false); const onSaveRef = useRef(onSave); const onStatusRef = useRef(onStatusChange); const enabledRef = useRef(enabled); - - useEffect(() => { - onSaveRef.current = onSave; - }, [onSave]); - - useEffect(() => { - onStatusRef.current = onStatusChange; - }, [onStatusChange]); - - useEffect(() => { - enabledRef.current = enabled; - }, [enabled]); - - const runSave = useCallback(async (): Promise => { - if (!enabledRef.current) return true; - // Cleared before the save so changes made while it runs mark dirty again. - dirtyRef.current = false; - onStatusRef.current?.('saving'); - try { - await onSaveRef.current(); - onStatusRef.current?.('saved'); - return true; - } catch (error) { - dirtyRef.current = true; - if (__DEV__) console.warn('[useAutosave] save failed', error); - onStatusRef.current?.('error'); - return false; - } - }, []); - - const flushNow = useCallback(async (): Promise => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (inFlightRef.current) { - await inFlightRef.current; - } - if (!dirtyRef.current) return true; - const promise = runSave(); - inFlightRef.current = promise; - return promise.finally(() => { - if (inFlightRef.current === promise) inFlightRef.current = null; - }); - }, [runSave]); - - const cancelPending = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - onStatusRef.current?.('idle'); - }, []); - - const schedule = useCallback(() => { - if (!enabledRef.current) return; - dirtyRef.current = true; - onStatusRef.current?.('pending'); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - timerRef.current = null; - const promise = runSave().finally(() => { - if (inFlightRef.current === promise) inFlightRef.current = null; - }); - inFlightRef.current = promise; - }, debounceMs); - }, [debounceMs, runSave]); + onSaveRef.current = onSave; + onStatusRef.current = onStatusChange; + enabledRef.current = enabled; + + const [queue] = useState(() => createAutosaveQueue({ + save: () => onSaveRef.current(), + onStatus: status => onStatusRef.current?.(status), + isEnabled: () => enabledRef.current, + debounceMs, + onError: error => { if (__DEV__) console.warn('[useAutosave] save failed', error); }, + isSuperseded: isDocumentCaptureSuperseded, + })); // Flush the moment the app leaves the foreground: JS timers do not fire in // the background, so a pending debounced save would otherwise sit unsaved @@ -98,20 +42,13 @@ export function useAutosave({ useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state === 'background' || state === 'inactive') { - void flushNow(); + void queue.flushNow(); } }); return () => subscription.remove(); - }, [flushNow]); + }, [queue]); - useEffect(() => { - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, []); + useEffect(() => () => queue.dispose(), [queue]); - return { schedule, flushNow, cancelPending }; + return { schedule: queue.schedule, flushNow: queue.flushNow, cancelPending: queue.cancelPending }; } diff --git a/src/hooks/useViewportTransform.ts b/src/hooks/useViewportTransform.ts index 720a093..19c3bb4 100644 --- a/src/hooks/useViewportTransform.ts +++ b/src/hooks/useViewportTransform.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { useCallback, useRef } from 'react'; import type { InfiniteInkViewportTransform } from '@mathnotes/mobile-ink'; export interface ViewportTransformStore { @@ -29,21 +29,17 @@ export function createViewportTransformStore(): ViewportTransformStore { return { transform: current, onTransformChange, subscribe, getSnapshot }; } -export function useViewportTransformStore(): { - store: ViewportTransformStore; - transform: InfiniteInkViewportTransform | null; -} { +/** + * The store receives every viewport frame. It is deliberately not subscribed + * here: the editor screen would otherwise re-render ~60 times a second during + * pan and zoom. Consumers that need frames subscribe to the store directly. + */ +export function useViewportTransformStore(): { store: ViewportTransformStore } { const storeRef = useRef(null); if (!storeRef.current) { storeRef.current = createViewportTransformStore(); } - const store = storeRef.current; - const transform = useSyncExternalStore( - store.subscribe, - store.getSnapshot, - () => null, - ); - return { store, transform }; + return { store: storeRef.current }; } export interface PageRect { diff --git a/src/services/exportService.ts b/src/services/exportService.ts index 3a6a5a7..d34113e 100644 --- a/src/services/exportService.ts +++ b/src/services/exportService.ts @@ -2,7 +2,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import * as Print from 'expo-print'; import * as Sharing from 'expo-sharing'; import { PixelRatio } from 'react-native'; -import { batchExportPages, type SerializedNotebookData } from '@mathnotes/mobile-ink'; +import { batchExportPages, projectDocumentPages, type SerializedNotebookData } from '@mathnotes/mobile-ink'; import { buildPdfHtml, NOTE_PAGE_HEIGHT, @@ -12,11 +12,10 @@ import { type PrintablePage, } from './pdfExportHtml'; import { PDFUtilsModule } from './pdfUtilsModule'; +import { indexAssetsByPage } from '../utils/continuousAssets'; const EXPORT_SCALE = 1.0; const DEFAULT_NATIVE_BATCH_SIZE = 8; -const LARGE_NATIVE_BATCH_SIZE = 1; -const LARGE_NOTEBOOK_PREVIEW_THRESHOLD = 150; const NATIVE_BATCH_PAUSE_MS = 16; // Pages rendered, embedded as base64 and printed per pass. Each page raster // is several MB of base64 in the JS heap, so printing a whole notebook in @@ -43,22 +42,6 @@ function getPdfPageIndex( : pageIndex; } -async function toPrintableImageUri(uri: string | undefined): Promise { - if (!uri) return null; - if (uri.startsWith('data:image/')) return uri; - if (!uri.startsWith('file://')) return null; - - try { - const base64 = await FileSystem.readAsStringAsync(uri, { - encoding: FileSystem.EncodingType.Base64, - }); - return base64 ? `data:image/png;base64,${base64}` : null; - } catch (error) { - if (__DEV__) console.warn('[exportService] could not read preview image', uri, error); - return null; - } -} - function mimeTypeForImage(uri: string): string { const extension = uri.match(/\.([a-z0-9]+)(?:[?#]|$)/i)?.[1]?.toLowerCase(); switch (extension) { @@ -96,13 +79,14 @@ async function embedInsertedImage(uri: string): Promise { } async function preparePrintablePages( - notebookPages: SerializedNotebookData['pages'], + indexedAssets: ReturnType, rasterUris: string[], + pageIndexes: number[], ): Promise { const cachedImages = new Map(); const pages: PrintablePage[] = []; - for (let index = 0; index < notebookPages.length; index += 1) { - const page = notebookPages[index]; + for (let index = 0; index < pageIndexes.length; index += 1) { + const page = indexedAssets[pageIndexes[index]]; const images: PrintablePage['images'] = []; for (const element of page.insertedElements ?? []) { const sourceUri = element.sourceUri ?? element.renderedImageUri; @@ -147,8 +131,13 @@ async function renderNativePagesInChunks(options: { for (let start = 0; start < pageIndexes.length; start += batchSize) { const chunkPageIndexes = pageIndexes.slice(start, start + batchSize); const chunkPages = chunkPageIndexes.map((pageIndex) => data.pages[pageIndex]); + const inkPayloads = await projectDocumentPages( + data.pages.map(page => page.data ?? '{"pages":{}}'), + exportHeight, + chunkPageIndexes, + ); const pngUris = await batchExportPages( - chunkPages.map((p) => p?.data ?? '{"pages":{}}'), + inkPayloads, chunkPages.map((p) => p?.pageType ?? 'plain'), exportWidth, exportHeight, @@ -182,40 +171,17 @@ async function collectExportImages(options: { pdfBackgroundUri?: string | null; }): Promise { const { data, pageIndexes, exportWidth, exportHeight, pdfBackgroundUri } = options; - const images = new Map(); - const nativePageIndexes: number[] = []; - const preferStoredPreviews = data.pages.length >= LARGE_NOTEBOOK_PREVIEW_THRESHOLD; - - if (preferStoredPreviews) { - for (const pageIndex of pageIndexes) { - const previewUri = await toPrintableImageUri(data.pages[pageIndex]?.previewUri); - if (previewUri) { - images.set(pageIndex, previewUri); - } else { - nativePageIndexes.push(pageIndex); - } - } - } else { - nativePageIndexes.push(...pageIndexes); - } - - if (nativePageIndexes.length > 0) { - const rendered = await renderNativePagesInChunks({ - data, - pageIndexes: nativePageIndexes, - exportWidth, - exportHeight, - pdfBackgroundUri, - batchSize: preferStoredPreviews ? LARGE_NATIVE_BATCH_SIZE : DEFAULT_NATIVE_BATCH_SIZE, - }); - for (const [pageIndex, uri] of rendered) { - images.set(pageIndex, uri); - } - } - - return pageIndexes - .map((pageIndex) => images.get(pageIndex)) - .filter((uri): uri is string => typeof uri === 'string' && uri.length > 0); + // Saved thumbnails may omit ink owned by a neighboring page. Always render + // the canonical document, in bounded batches, for the exported PDF. + const rendered = await renderNativePagesInChunks({ + data, pageIndexes, exportWidth, exportHeight, pdfBackgroundUri, + batchSize: DEFAULT_NATIVE_BATCH_SIZE, + }); + return pageIndexes.map(index => { + const uri = rendered.get(index); + if (!uri) throw new Error(`Native export omitted page ${index + 1}`); + return uri; + }); } async function mergePrintedPasses(passUris: string[]): Promise { @@ -251,6 +217,7 @@ export async function exportNotebookAsPdf( const exportWidth = Math.round(NOTE_PAGE_WIDTH * nativeScale); const exportHeight = Math.round(NOTE_PAGE_HEIGHT * nativeScale); const pageCount = data.pages.length; + const indexedAssets = indexAssetsByPage(data.pages, NOTE_PAGE_HEIGHT); for (let start = 0; start < pageCount; start += PRINT_PASS_PAGES) { const pageIndexes = Array.from( @@ -272,8 +239,9 @@ export async function exportNotebookAsPdf( } const printablePages = await preparePrintablePages( - pageIndexes.map((pageIndex) => data.pages[pageIndex]), + indexedAssets, rasterUris, + pageIndexes, ); const printed = await Print.printToFileAsync({ html: buildPdfHtml(printablePages), diff --git a/src/utils/continuousAssets.ts b/src/utils/continuousAssets.ts new file mode 100644 index 0000000..b47d8ec --- /dev/null +++ b/src/utils/continuousAssets.ts @@ -0,0 +1,73 @@ +import type { InkTextBox, InsertedElement, NotebookPage } from '@mathnotes/mobile-ink'; + +/** Assets retain one owner page; editing uses notebook coordinates. */ +export function assetInNotebook(asset: T, owner: number, pageHeight: number): T { + return { ...asset, y: (asset.y ?? 0) + owner * pageHeight }; +} + +export function patchForOwner(patch: T, owner: number, pageHeight: number): T { + return patch.y === undefined ? patch : { ...patch, y: patch.y - owner * pageHeight }; +} + +type AssetBounds = { y?: number; width?: number; height?: number; rotation?: number }; +type PageAssets = { insertedElements: InsertedElement[]; textBoxes: InkTextBox[] }; + +function verticalBounds(asset: AssetBounds, defaultWidth: number, defaultHeight: number) { + const width = asset.width ?? defaultWidth; + const height = asset.height ?? defaultHeight; + const angle = (asset.rotation ?? 0) * Math.PI / 180; + const halfExtent = (Math.abs(Math.sin(angle)) * width + Math.abs(Math.cos(angle)) * height) / 2; + const center = (asset.y ?? 0) + height / 2; + return { top: center - halfExtent, bottom: center + halfExtent }; +} + +/** Index once per export: cost follows assets and overlaps, not pages × assets. */ +export function indexAssetsByPage(pages: NotebookPage[], pageHeight: number): PageAssets[] { + if (!Number.isFinite(pageHeight) || pageHeight <= 0) throw new Error('Invalid page height'); + const indexed: PageAssets[] = pages.map(() => ({ insertedElements: [], textBoxes: [] })); + function project( + asset: T, owner: number, defaultWidth: number, defaultHeight: number, + append: (target: number, local: T) => void, + ) { + const global = assetInNotebook(asset, owner, pageHeight); + const bounds = verticalBounds(global, defaultWidth, defaultHeight); + if (!Number.isFinite(bounds.top) || !Number.isFinite(bounds.bottom)) { + throw new Error('Cannot export an asset with invalid geometry'); + } + const first = Math.max(0, Math.floor(bounds.top / pageHeight)); + const last = Math.min(pages.length - 1, Math.ceil(bounds.bottom / pageHeight) - 1); + for (let target = first; target <= last; target += 1) { + append(target, assetInNotebook(asset, owner - target, pageHeight)); + } + } + pages.forEach((page, owner) => { + for (const asset of page.insertedElements ?? []) { + project(asset, owner, 240, 240, (target, local) => indexed[target].insertedElements.push(local)); + } + for (const asset of page.textBoxes ?? []) { + project(asset, owner, 200, 100, (target, local) => indexed[target].textBoxes.push(local)); + } + }); + return indexed; +} + +/** Export projections only: never persist these copies or give them new IDs. */ +export function assetsForPage(pages: NotebookPage[], target: number, pageHeight: number): PageAssets { + return indexAssetsByPage(pages, pageHeight)[target] ?? { insertedElements: [], textBoxes: [] }; +} + +export function duplicateAssetPosition( + asset: { x: number; y: number; width?: number; height?: number }, + owner: number, + pageWidth: number, + pageHeight: number, + pageCount: number, +) { + const globalY = owner * pageHeight + asset.y; + return { + x: Math.max(0, Math.min(pageWidth - (asset.width ?? 80), asset.x + 20)), + y: Math.max(0, Math.min( + pageCount * pageHeight - (asset.height ?? 80), globalY + 20, + )) - owner * pageHeight, + }; +} diff --git a/src/utils/overlayTouchRects.ts b/src/utils/overlayTouchRects.ts new file mode 100644 index 0000000..d515c7b --- /dev/null +++ b/src/utils/overlayTouchRects.ts @@ -0,0 +1,24 @@ +import type {NotebookPage, TouchExclusionRect} from '@mathnotes/mobile-ink'; + +type Selection = {type: 'image' | 'text'; pageIndex: number; id: string}; +type Layout = {pageWidth: number; pageHeight: number; containerWidth: number; contentPadding: number}; + +/** Keep a selected body, handles and action bar out of the parent pan gesture. */ +export function selectedOverlayTouchRects(pages: NotebookPage[], selection: Selection | null, + layout: Layout): TouchExclusionRect[] { + if (!selection) return []; + const page = pages[selection.pageIndex]; + const item = selection.type === 'image' ? page?.insertedElements?.find(value => value.id === selection.id) + : page?.textBoxes?.find(value => value.id === selection.id); + if (!item) return []; + const width = item.width ?? (selection.type === 'image' ? 240 : 200); + const height = item.height ?? (selection.type === 'image' ? 240 : 100); + const angle = ('rotation' in item ? item.rotation ?? 0 : 0) * Math.PI / 180; + if (![item.x, item.y, width, height, angle].every(Number.isFinite) || width <= 0 || height <= 0) return []; + const rotatedWidth = Math.abs(Math.cos(angle)) * width + Math.abs(Math.sin(angle)) * height; + const rotatedHeight = Math.abs(Math.sin(angle)) * width + Math.abs(Math.cos(angle)) * height; + const centerX = (layout.containerWidth - layout.pageWidth) / 2 + item.x + width / 2; + const centerY = layout.contentPadding + selection.pageIndex * layout.pageHeight + item.y + height / 2; + return [{left: centerX - rotatedWidth / 2 - 36, right: centerX + rotatedWidth / 2 + 36, + top: centerY - rotatedHeight / 2 - 96, bottom: centerY + rotatedHeight / 2 + 36}]; +} diff --git a/vendor/mathnotes-mobile-ink-0.3.10.tgz b/vendor/mathnotes-mobile-ink-0.3.10.tgz new file mode 100644 index 0000000..cf8c0db Binary files /dev/null and b/vendor/mathnotes-mobile-ink-0.3.10.tgz differ diff --git a/vendor/mobile-ink-source.json b/vendor/mobile-ink-source.json new file mode 100644 index 0000000..3e5372c --- /dev/null +++ b/vendor/mobile-ink-source.json @@ -0,0 +1,8 @@ +{ + "package": "@mathnotes/mobile-ink", + "version": "0.3.10", + "source": "https://github.com/mathnotes-app/mobile-ink", + "commit": "fbb8017594896ebcaf0f6ddf6b7632c7250a2a1d", + "pull_request": "https://github.com/mathnotes-app/mobile-ink/pull/61", + "sha256": "49de52afa68fbd478283b456cf63d3cb34af07272cc612a22cad03aecc337010" +}