diff --git a/app.json b/app.json index 329e50e..1540413 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,7 @@ "ios": { "bundleIdentifier": "com.builderpro.opennotes", "icon": "./assets/icon.png", - "buildNumber": "18", + "buildNumber": "19", "supportsTablet": true, "usesIcloudStorage": true, "entitlements": { diff --git a/app/note/[id].tsx b/app/note/[id].tsx index b5274b7..de495e3 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -1,4 +1,3 @@ -import {useOverlayHistory} from '../../src/components/editor/history/useOverlayHistory'; import React, { useCallback, useEffect, @@ -24,8 +23,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { - ContinuousDocumentCanvas as InfiniteInkCanvas, - type ContinuousDocumentCanvasRef, + InfiniteInkCanvas, + type InfiniteInkCanvasRef, type InkTextBox, type InsertedElement, type NotebookPage, @@ -62,7 +61,6 @@ 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'; @@ -137,7 +135,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); @@ -150,20 +148,13 @@ 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); - const replaceOverlayMap = useCallback((next: Map) => { - overlayMapRef.current = next; - setOverlayMap(next); - }, []); + useEffect(() => { + overlayMapRef.current = overlayMap; + }, [overlayMap]); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [autosaveStatus, setAutosaveStatus] = useState('idle'); @@ -212,7 +203,6 @@ 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) => { @@ -247,13 +237,6 @@ 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 () => { @@ -293,32 +276,23 @@ 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; - pendingBodyRef.current = null; - setBodyReady(false); - setBodyLoadFailed(true); - }, []); - const applyNotebookData = useCallback( (notebookData: SerializedNotebookData) => { rememberPagePreviews(notebookData.pages); - updateEnginePages(mergeStoredPreviews(notebookData.pages)); + setEnginePages(mergeStoredPreviews(notebookData.pages)); const initialMap = new Map(); for (const page of notebookData.pages) { initialMap.set(page.id, overlayFromPage(page)); } - replaceOverlayMap(initialMap); + setOverlayMap(initialMap); setBodyReady(true); - if (canvasReadyRef.current && canvasRef.current) { - void canvasRef.current.loadNotebookData(notebookData).catch(handleCanvasLoadError); + if (canvasReadyRef.current) { + void canvasRef.current?.loadNotebookData(notebookData); } else { pendingBodyRef.current = notebookData; } }, - [mergeStoredPreviews, rememberPagePreviews, handleCanvasLoadError], + [mergeStoredPreviews, rememberPagePreviews], ); // Initial load @@ -328,7 +302,7 @@ export default function NoteScreen() { storedPreviewByPageIdRef.current = new Map(); setBodyReady(false); setBodyLoadFailed(false); - updateEnginePages([]); + setEnginePages([]); setCurrentPageIndex(0); setLoading(true); (async () => { @@ -383,18 +357,17 @@ export default function NoteScreen() { const pending = pendingBodyRef.current; if (pending) { pendingBodyRef.current = null; - void canvasRef.current?.loadNotebookData(pending).catch(handleCanvasLoadError); + void canvasRef.current?.loadNotebookData(pending); } - }, [handleCanvasLoadError]); + }, []); const handlePagesChange = useCallback((next: NotebookPage[]) => { if (!isMountedRef.current || exitRef.current === 'navigating') return; rememberPagePreviews(next); const pagesWithPreviews = mergeStoredPreviews(next); - updateEnginePages(pagesWithPreviews); + setEnginePages(pagesWithPreviews); // Make sure every page id has an overlay slot; drop overlays for removed pages. - { - const prev = overlayMapRef.current; + setOverlayMap((prev) => { let changed = false; const nextMap = new Map(prev); const idsInPages = new Set(pagesWithPreviews.map((p) => p.id)); @@ -410,8 +383,8 @@ export default function NoteScreen() { changed = true; } } - if (changed) replaceOverlayMap(nextMap); - } + return changed ? nextMap : prev; + }); }, [mergeStoredPreviews, rememberPagePreviews]); const handleDrawingChange = useCallback(() => { @@ -486,13 +459,13 @@ export default function NoteScreen() { ); const handleUndo = useCallback(() => { - history.undoHistory(); + canvasRef.current?.undo(); scheduleAutosave(); - }, [scheduleAutosave, history.undoHistory]); + }, [scheduleAutosave]); const handleRedo = useCallback(() => { - history.redoHistory(); + canvasRef.current?.redo(); scheduleAutosave(); - }, [scheduleAutosave, history.redoHistory]); + }, [scheduleAutosave]); const handleJumpToPage = useCallback( (pageIndex: number) => { @@ -523,13 +496,18 @@ export default function NoteScreen() { pageIndex: number, updater: (state: PageOverlayState) => PageOverlayState, ) => { - const pageId = enginePagesRef.current[pageIndex]?.id; - if (!pageId) return; - history.setOverlayPages(pages => pages.map(page => page.id === pageId - ? {...page, ...updater(overlayFromPage(page))} : page)); + 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; + }); scheduleAutosave(); }, - [history.setOverlayPages, scheduleAutosave], + [enginePages, scheduleAutosave], ); const applyInsertedImage = useCallback( @@ -666,15 +644,12 @@ export default function NoteScreen() { const now = new Date().toISOString(); const nextId = insertedElementId(); - const maxZIndex = Array.from(overlayMapRef.current.values()).reduce( - (highest, overlay) => overlay.insertedElements.reduce( - (value, asset) => Math.max(value, asset.zIndex ?? 0), highest, - ), 0, - ); + const maxZIndex = Math.max(0, ...state.insertedElements.map((el) => el.zIndex ?? 0)); const duplicated: InsertedElement = { ...element, id: nextId, - ...duplicateAssetPosition(element, pageIndex, PAGE_WIDTH, PAGE_HEIGHT, enginePages.length), + 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)), zIndex: maxZIndex + 1, createdAt: now, updatedAt: now, @@ -721,7 +696,6 @@ 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) => { @@ -934,10 +908,7 @@ 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} diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index fff93d8..d487137 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -11,46 +11,29 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 4788858903E15B3CAEB129E6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */; }; - 511FEA48B7554353BD76B271 /* NotebookInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33A61D4CAFBD88E11AEFF939 /* NotebookInteractionUITests.swift */; }; 7D3A80162E1A4CC89B1B09C1 /* PDFUtilsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */; }; 7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */; }; 7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */; }; 7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */; }; 9C2FD7C620026BB779139CCD /* libPods-OpenNotes.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A7A85B485F4321855B927960 /* libPods-OpenNotes.a */; }; - AA1519DCA43664DFE64E36E9 /* InkVisibilityAssertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4709944072AD746778B1F8 /* InkVisibilityAssertions.swift */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; - E8210B97ECFC3A28A05FADD9 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFDCECAB8FCBFB2881B4072A /* Foundation.framework */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - 442E9B63CACEF7FB3ECB1E70 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 13B07F861A680F5B00A75B9A; - remoteInfo = OpenNotes; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXFileReference section */ 13B07F961A680F5B00A75B9A /* OpenNotes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenNotes.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OpenNotes/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OpenNotes/Info.plist; sourceTree = ""; }; - 33A61D4CAFBD88E11AEFF939 /* NotebookInteractionUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotebookInteractionUITests.swift; sourceTree = ""; }; 575BE98DD16F76BFF21A39E6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OpenNotes/PrivacyInfo.xcprivacy; sourceTree = ""; }; 7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PDFUtilsModule.swift; path = OpenNotes/PDFUtilsModule.swift; sourceTree = ""; }; 7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PDFUtilsModule.m; path = OpenNotes/PDFUtilsModule.m; sourceTree = ""; }; 7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ICloudBackupModule.swift; path = OpenNotes/ICloudBackupModule.swift; sourceTree = ""; }; 7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ICloudBackupModule.m; path = OpenNotes/ICloudBackupModule.m; sourceTree = ""; }; - 7E4709944072AD746778B1F8 /* InkVisibilityAssertions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InkVisibilityAssertions.swift; sourceTree = ""; }; 9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OpenNotes/ExpoModulesProvider.swift"; sourceTree = ""; }; A7A85B485F4321855B927960 /* libPods-OpenNotes.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OpenNotes.a"; sourceTree = BUILT_PRODUCTS_DIR; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OpenNotes/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; - BFDCECAB8FCBFB2881B4072A /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; CEB267F85878647A5ACA788A /* Pods-OpenNotes.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OpenNotes.release.xcconfig"; path = "Target Support Files/Pods-OpenNotes/Pods-OpenNotes.release.xcconfig"; sourceTree = ""; }; - E4A6B347EDF0BEED4C56B75E /* OpenNotesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenNotesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E6710FB66EE811C212116106 /* Pods-OpenNotes.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OpenNotes.debug.xcconfig"; path = "Target Support Files/Pods-OpenNotes/Pods-OpenNotes.debug.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OpenNotes/AppDelegate.swift; sourceTree = ""; }; @@ -66,25 +49,9 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F6918DBDA9D6E1EA5434DC60 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - E8210B97ECFC3A28A05FADD9 /* Foundation.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 113AAC429982245F287A514B /* iOS */ = { - isa = PBXGroup; - children = ( - BFDCECAB8FCBFB2881B4072A /* Foundation.framework */, - ); - name = iOS; - sourceTree = ""; - }; 13B07FAE1A68108700A75B9A /* OpenNotes */ = { isa = PBXGroup; children = ( @@ -108,7 +75,6 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, A7A85B485F4321855B927960 /* libPods-OpenNotes.a */, - 113AAC429982245F287A514B /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -137,7 +103,6 @@ 2D16E6871FA4F8E400B85C8A /* Frameworks */, CCEB29EB4EA264547E3F22DF /* Pods */, B025AFE641A7B146AB64069B /* ExpoModulesProviders */, - 94F4EBFAAF2787ED7D01F434 /* OpenNotesUITests */, ); indentWidth = 2; sourceTree = ""; @@ -148,21 +113,10 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* OpenNotes.app */, - E4A6B347EDF0BEED4C56B75E /* OpenNotesUITests.xctest */, ); name = Products; sourceTree = ""; }; - 94F4EBFAAF2787ED7D01F434 /* OpenNotesUITests */ = { - isa = PBXGroup; - children = ( - 7E4709944072AD746778B1F8 /* InkVisibilityAssertions.swift */, - 33A61D4CAFBD88E11AEFF939 /* NotebookInteractionUITests.swift */, - ); - name = OpenNotesUITests; - path = OpenNotesUITests; - sourceTree = ""; - }; B025AFE641A7B146AB64069B /* ExpoModulesProviders */ = { isa = PBXGroup; children = ( @@ -215,24 +169,6 @@ productReference = 13B07F961A680F5B00A75B9A /* OpenNotes.app */; productType = "com.apple.product-type.application"; }; - F2556A6F7BB9D8A59CD13656 /* OpenNotesUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 2096F621AB0874DB9EE21092 /* Build configuration list for PBXNativeTarget "OpenNotesUITests" */; - buildPhases = ( - 3863A37677E3B07E81B282F9 /* Sources */, - F6918DBDA9D6E1EA5434DC60 /* Frameworks */, - 0594059E144C9886F2C516CC /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 2F854E704CAB0A50FE497853 /* PBXTargetDependency */, - ); - name = OpenNotesUITests; - productName = OpenNotesUITests; - productReference = E4A6B347EDF0BEED4C56B75E /* OpenNotesUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -262,19 +198,11 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* OpenNotes */, - F2556A6F7BB9D8A59CD13656 /* OpenNotesUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 0594059E144C9886F2C516CC /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 13B07F8E1A680F5B00A75B9A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -424,26 +352,8 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 3863A37677E3B07E81B282F9 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - AA1519DCA43664DFE64E36E9 /* InkVisibilityAssertions.swift in Sources */, - 511FEA48B7554353BD76B271 /* NotebookInteractionUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 2F854E704CAB0A50FE497853 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = OpenNotes; - target = 13B07F861A680F5B00A75B9A /* OpenNotes */; - targetProxy = 442E9B63CACEF7FB3ECB1E70 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -454,7 +364,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -493,7 +403,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -635,41 +545,6 @@ }; name = Release; }; - 9273CECCEBBCD90F0398488F /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGN_STYLE = Automatic; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; - OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.builderpro.opennotes.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = OpenNotes; - }; - name = Debug; - }; - FA5BBFAC3A302DC5B2E8EE18 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGN_STYLE = Automatic; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; - OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.builderpro.opennotes.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = OpenNotes; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -682,15 +557,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 2096F621AB0874DB9EE21092 /* Build configuration list for PBXNativeTarget "OpenNotesUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - FA5BBFAC3A302DC5B2E8EE18 /* Release */, - 9273CECCEBBCD90F0398488F /* Debug */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OpenNotes" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/OpenNotes.xcodeproj/xcshareddata/xcschemes/OpenNotesIntegrity.xcscheme b/ios/OpenNotes.xcodeproj/xcshareddata/xcschemes/OpenNotesIntegrity.xcscheme deleted file mode 100644 index e473a03..0000000 --- a/ios/OpenNotes.xcodeproj/xcshareddata/xcschemes/OpenNotesIntegrity.xcscheme +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index 0213f89..db1dc29 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -54,7 +54,7 @@ CFBundleVersion - 18 + 19 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/ios/OpenNotesUITests/InkVisibilityAssertions.swift b/ios/OpenNotesUITests/InkVisibilityAssertions.swift deleted file mode 100644 index c1ec9e1..0000000 --- a/ios/OpenNotesUITests/InkVisibilityAssertions.swift +++ /dev/null @@ -1,72 +0,0 @@ -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/NotebookInteractionUITests.swift b/ios/OpenNotesUITests/NotebookInteractionUITests.swift deleted file mode 100644 index 9cd56df..0000000 --- a/ios/OpenNotesUITests/NotebookInteractionUITests.swift +++ /dev/null @@ -1,445 +0,0 @@ -import XCTest -import UIKit - -final class NotebookInteractionUITests: XCTestCase { - override func setUpWithError() throws { continueAfterFailure = false } - - private func element(_ app: XCUIApplication, _ id: String) -> XCUIElement { - app.descendants(matching: .any).matching(identifier: id).firstMatch - } - - private func tap(_ app: XCUIApplication, _ id: String) { - let item = element(app, id) - XCTAssertTrue(item.waitForExistence(timeout: 15), "Missing \(id)") - item.tap() - } - - private func prepare() -> XCUIApplication { - let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") - app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] - app.launch() - 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() } - 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 - } - - private func attach(_ app: XCUIApplication, _ name: String) { - let screenshot = XCTAttachment(screenshot: app.screenshot()) - screenshot.name = name - screenshot.lifetime = .keepAlways - add(screenshot) - } - - private 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 - measure(metrics: [XCTCPUMetric(application: app), XCTMemoryMetric(application: app), - XCTHitchMetric(application: app)], options: options, block: action) - } - - func testInvalidNativePayloadShowsRecoveryWithoutOverwritingNote() { - let app = XCUIApplication(bundleIdentifier: "com.builderpro.opennotes") - app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] - app.launch() - 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() - app.launch() - 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"] - app.launch() - 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() - app.launch() - 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"] - app.launch() - 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"] - app.launch() - 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) - 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() - app.launch() - 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 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/README.md b/ios/OpenNotesUITests/README.md deleted file mode 100644 index 460a492..0000000 --- a/ios/OpenNotesUITests/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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. - -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.lock b/ios/Podfile.lock index 6640214..03d4228 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.9): + - MathNotesMobileInk (0.3.6): - React-Core - react-native-skia - RCTDeprecation (0.81.5) @@ -2552,7 +2552,7 @@ SPEC CHECKSUMS: EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 - MathNotesMobileInk: aba86894baa524180e07594ec138906806630991 + MathNotesMobileInk: affc434b8ea8baee9686500bcc89fa5c40ac861c RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c diff --git a/package-lock.json b/package-lock.json index 4782ebe..a46c8b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@expo/vector-icons": "^15.0.3", - "@mathnotes/mobile-ink": "0.3.9", + "@mathnotes/mobile-ink": "0.3.6", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/react-native-skia": "2.2.12", "expo": "54.0.34", @@ -2565,9 +2565,9 @@ } }, "node_modules/@mathnotes/mobile-ink": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.9.tgz", - "integrity": "sha512-pDPKs/tBIgjGyAkveBui2jGXkc4KLWl1gOrNT4VWaicrmV2Yw6NOEIxWHHtUXJVKO1Tm+/VWvsmHTjlmSS9uyA==", + "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==", "license": "Apache-2.0", "peerDependencies": { "@shopify/react-native-skia": ">=2.0.0", @@ -3526,7 +3526,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4546,7 +4546,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -7697,26 +7697,6 @@ } } }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, - "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", @@ -8941,7 +8921,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 32d981c..883fe2a 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,13 @@ "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 scripts/continuousAssets.test.mjs scripts/overlayHistory.test.mjs scripts/overlayGeometry.test.mjs scripts/nativeCallback.test.mjs scripts/autosaveQueue.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:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "@expo/vector-icons": "^15.0.3", - "@mathnotes/mobile-ink": "0.3.9", + "@mathnotes/mobile-ink": "0.3.6", "@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 deleted file mode 100644 index b77f1b4..0000000 --- a/scripts/autosaveQueue.test.mjs +++ /dev/null @@ -1,88 +0,0 @@ -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/continuousAssets.test.mjs b/scripts/continuousAssets.test.mjs deleted file mode 100644 index e539bea..0000000 --- a/scripts/continuousAssets.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -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 f949f23..fbfab44 100644 --- a/scripts/exportService.test.mjs +++ b/scripts/exportService.test.mjs @@ -3,7 +3,6 @@ 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( @@ -28,7 +27,6 @@ function notebook(pages) { function harness(files = {}, { mergePdfFiles } = {}) { const printCalls = []; const batchCalls = []; - const projectionCalls = []; const fileReads = []; const deletedFiles = []; const mergeCalls = []; @@ -51,10 +49,6 @@ 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)); @@ -83,7 +77,6 @@ function harness(files = {}, { mergePdfFiles } = {}) { }; } if (name === '@mathnotes/mobile-ink') return mobileInk; - if (name === '../utils/continuousAssets') return { indexAssetsByPage }; if (name === './pdfExportHtml') { return { buildPdfHtml, @@ -100,7 +93,6 @@ function harness(files = {}, { mergePdfFiles } = {}) { exportNotebookAsPdf: exports.exportNotebookAsPdf, printCalls, batchCalls, - projectionCalls, fileReads, deletedFiles, mergeCalls, @@ -264,43 +256,3 @@ 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/nativeCallback.test.mjs b/scripts/nativeCallback.test.mjs deleted file mode 100644 index c42f900..0000000 --- a/scripts/nativeCallback.test.mjs +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index fcd41cb..0000000 --- a/scripts/overlayGeometry.test.mjs +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 6baf834..0000000 --- a/scripts/overlayHistory.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -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 846e18c..c4f0e00 100644 --- a/scripts/overlayTextInsertion.test.mjs +++ b/scripts/overlayTextInsertion.test.mjs @@ -3,18 +3,15 @@ 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, customPages) { +function harness(transform, pageCount = 3) { 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, () => {}], @@ -37,7 +34,6 @@ function harness(transform, pageCount = 3, customPages) { } 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 }, @@ -45,21 +41,18 @@ function harness(transform, pageCount = 3, customPages) { './TextBoxOverlay': { TextBoxOverlay: 'TextBoxOverlay' }, './ImageInsertOverlay': { ImageInsertOverlay: 'ImageInsertOverlay' }, '../../hooks/useViewportTransform': viewport, - '../../utils/continuousAssets': continuousAssets, }); const tree = OverlayLayer({ store: { getSnapshot: () => transform }, - pages: customPages ?? Array.from({ length: pageCount }, (_, i) => ({ id: String(i) })), + pages: 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, tree, updates, + created, selected, tap: (x, y) => tree.children[0].props.onPress({ nativeEvent: { locationX: x, locationY: y } }), }; } @@ -100,40 +93,3 @@ 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/seed-native-audit.py b/scripts/seed-native-audit.py deleted file mode 100644 index ef50e53..0000000 --- a/scripts/seed-native-audit.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/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 audit-performance-* notes 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/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index e66cc3d..aa14a69 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -50,9 +50,6 @@ export function EditorHeader({ > [styles.sideButton, pressed && { opacity: 0.5 }]} diff --git a/src/components/editor/ImageInsertOverlay.tsx b/src/components/editor/ImageInsertOverlay.tsx index e6c64d1..d327a7f 100644 --- a/src/components/editor/ImageInsertOverlay.tsx +++ b/src/components/editor/ImageInsertOverlay.tsx @@ -1,5 +1,3 @@ -import {useNativeCallback} from '../../hooks/native-callback'; -import {resizeFromCorner, localPointerDelta, anchorRotatedFrame, cropFromEdge, type ResizeHandle} from './overlayGeometry'; import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, @@ -25,9 +23,12 @@ 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' @@ -43,7 +44,6 @@ type CropRect = NonNullable; export interface ImageInsertOverlayProps { element: InsertedElement; pageScale: number; - gestureScale?: SharedValue; screenLeft: number; screenTop: number; pageWidth: number; @@ -69,11 +69,68 @@ 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, @@ -87,7 +144,6 @@ export function ImageInsertOverlay({ onSendToBack, onRemove, }: ImageInsertOverlayProps) { - const nativeSelect = useNativeCallback(onSelect); const theme = useTheme(); const [isCropMode, setIsCropMode] = useState(false); @@ -95,7 +151,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) && !locked; + const canCrop = element.type === 'image' && Boolean(sourceUri); const preserveAspectRatio = element.type === 'latex-figure'; const x = useSharedValue(element.x); @@ -146,7 +202,6 @@ export function ImageInsertOverlay({ cropX, cropY, element.cropRect, - element.rotation, element.height, element.rotation, element.width, @@ -172,13 +227,14 @@ export function ImageInsertOverlay({ if (!isSelected) setIsCropMode(false); }, [isSelected]); - const commitPosition = useNativeCallback( + const commitPosition = useCallback( (nextX: number, nextY: number) => { onUpdate({ x: nextX, y: nextY }); }, + [onUpdate], ); - const commitSize = useNativeCallback( + const commitSize = useCallback( (nextWidth: number, nextHeight: number, nextX: number, nextY: number) => { onUpdate({ x: nextX, @@ -187,17 +243,19 @@ export function ImageInsertOverlay({ height: nextHeight, }); }, + [onUpdate], ); - const commitRotation = useNativeCallback( + const commitRotation = useCallback( (nextRotation: number) => { onUpdate({ rotation: nextRotation }); }, + [onUpdate], ); - const selectFromFinger = useNativeCallback(() => { + const selectFromFinger = useCallback(() => { onSelect(); - }); + }, [onSelect]); const handleFingerPointerDown = useCallback((event: any) => { const pointerType = event.nativeEvent.pointerType; @@ -214,11 +272,8 @@ 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 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; + const fullX = element.x - element.cropRect.x * fullWidth; + const fullY = element.y - element.cropRect.y * fullHeight; x.value = fullX; y.value = fullY; @@ -240,7 +295,6 @@ export function ImageInsertOverlay({ cropX, cropY, element.cropRect, - element.rotation, element.height, element.width, element.x, @@ -256,21 +310,16 @@ export function ImageInsertOverlay({ y, ]); - const dragOriginX = useSharedValue(0); - const dragOriginY = useSharedValue(0); - const dragGesture = Gesture.Pan().maxPointers(1) + const dragGesture = Gesture.Pan() .enabled(isSelected && !isCropMode) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { 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) => { @@ -290,21 +339,21 @@ export function ImageInsertOverlay({ 'worklet'; if (locked || !hasMoved.value) return; x.value = clamp( - savedX.value + (evt.absoluteX - dragOriginX.value) / (gestureScale?.value ?? pageScale), + savedX.value + evt.translationX / pageScale, 0, Math.max(0, pageWidth - width.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 && hasMoved.value) {runOnJS(commitPosition)(x.value, y.value);} + y.value = clamp( + savedY.value + evt.translationY / pageScale, + 0, + Math.max(0, pageHeight - height.value), + ); }) - .onFinalize((_event, success) => { + .onEnd(() => { 'worklet'; - if (!success && hasMoved.value) {x.value = savedX.value; y.value = savedY.value;} - hasMoved.value = false; + if (hasMoved.value) { + runOnJS(commitPosition)(x.value, y.value); + } }); const tapGesture = Gesture.Tap() @@ -313,21 +362,21 @@ export function ImageInsertOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); } }) .onEnd((_evt, success) => { 'worklet'; - if (success) runOnJS(nativeSelect)(); + if (success) runOnJS(onSelect)(); }); - const rotateGesture = Gesture.Pan().maxPointers(1) + const rotateGesture = Gesture.Pan() .enabled(isSelected && !locked && !isCropMode) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } @@ -342,7 +391,7 @@ export function ImageInsertOverlay({ evt.absoluteY - rotateCenterY.value, evt.absoluteX - rotateCenterX.value, ); - runOnJS(nativeSelect)(); + runOnJS(onSelect)(); }) .onUpdate((evt) => { 'worklet'; @@ -354,26 +403,21 @@ export function ImageInsertOverlay({ savedRotation.value + ((currentAngle - rotateStartPointerAngle.value) * 180) / Math.PI; }) - .onEnd((_event, success) => { + .onEnd(() => { 'worklet'; - if (success) runOnJS(commitRotation)(rotation.value); - else rotation.value = savedRotation.value; + runOnJS(commitRotation)(rotation.value); }); - const resizeOriginX = useSharedValue(0); - const resizeOriginY = useSharedValue(0); const createResizeGesture = (direction: ResizeHandle) => - Gesture.Pan().maxPointers(1) + Gesture.Pan() .enabled(isSelected && !locked && !isCropMode) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } - const touch = event.allTouches[0]; - if (touch) {resizeOriginX.value = touch.absoluteX; resizeOriginY.value = touch.absoluteY;} stateManager.activate(); }) .onStart(() => { @@ -385,30 +429,26 @@ export function ImageInsertOverlay({ }) .onUpdate((evt) => { 'worklet'; - const delta = localPointerDelta(evt.absoluteX - resizeOriginX.value, - evt.absoluteY - resizeOriginY.value, gestureScale?.value ?? pageScale, rotation.value); - const resized = resizeFromCorner( + const next = resizeFromCorner( direction, savedX.value, savedY.value, savedWidth.value, savedHeight.value, - delta.x, - delta.y, + evt.translationX / pageScale, + evt.translationY / pageScale, 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((_event, success) => { + .onEnd(() => { 'worklet'; - 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; } + runOnJS(commitSize)(width.value, height.value, x.value, y.value); }); const animatedStyle = useAnimatedStyle(() => ({ @@ -430,8 +470,6 @@ export function ImageInsertOverlay({ return ( {!locked && !isCropMode ? ( - + ) : null} {canCrop ? ( setIsCropMode((value) => !value)} @@ -487,7 +524,6 @@ export function ImageInsertOverlay({ {isCropMode ? ( <> ) : null} { setIsCropMode(false); onUpdate({ locked: !locked }); }} + onPress={() => onUpdate({ locked: !locked })} /> ReturnType; }) { return ( <> {RESIZE_HANDLES.map((handle) => ( - + ))} @@ -681,10 +711,7 @@ function ResizeHandles({ } function CropHandles({ - elementId, - rotation, pageScale, - gestureScale, width, height, x, @@ -703,10 +730,7 @@ function CropHandles({ savedCropHeight, onUpdateCrop, }: { - elementId: string; - rotation: SharedValue; pageScale: number; - gestureScale?: SharedValue; width: SharedValue; height: SharedValue; x: SharedValue; @@ -731,16 +755,12 @@ function CropHandles({ y: number, ) => void; }) { - const nativeCrop = useNativeCallback(onUpdateCrop); - const touchStart = useSharedValue({x: 0, y: 0}); const createCropGesture = (edge: CropHandle) => - Gesture.Pan().maxPointers(1) + Gesture.Pan() .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - const touch = event.allTouches[0]; - if (touch) touchStart.value = {x: touch.absoluteX, y: touch.absoluteY}; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } @@ -762,25 +782,72 @@ function CropHandles({ }) .onUpdate((event) => { 'worklet'; - 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; + 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)); }) - .onEnd((_event, success) => { + .onEnd(() => { '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, @@ -792,7 +859,7 @@ function CropHandles({ nextCropRect.y <= 0.001 && nextCropRect.width >= 0.999 && nextCropRect.height >= 0.999; - runOnJS(nativeCrop)( + runOnJS(onUpdateCrop)( isFullImage ? undefined : nextCropRect, width.value, height.value, @@ -806,7 +873,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; @@ -167,7 +151,7 @@ export function OverlayLayer({ {captureTaps ? ( ) : null} @@ -177,7 +161,19 @@ export function OverlayLayer({ (a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0), ); return ( - + {elements.map((el) => { const canRenderElement = el.type === 'image' || @@ -190,36 +186,35 @@ export function OverlayLayer({ return canRenderElement ? ( onSelectionChange({ type: 'image', pageIndex, id: el.id }) } - onUpdate={(patch) => onUpdateInsertedElement(pageIndex, el.id, patchForOwner(patch, pageIndex, pageHeight))} + onUpdate={(patch) => onUpdateInsertedElement(pageIndex, el.id, patch)} onUpdateCrop={(cropRect, width, height, x, y) => onUpdateInsertedElement(pageIndex, el.id, { cropRect, width, height, x, - y: y === undefined ? undefined : y - pageIndex * pageHeight, + y, }) } onDuplicate={() => onDuplicateInsertedElement(pageIndex, el.id)} onBringToFront={() => { const nextZIndex = - imageLayerBounds.max + 1; + Math.max(0, ...elements.map((candidate) => candidate.zIndex ?? 0)) + 1; onUpdateInsertedElement(pageIndex, el.id, { zIndex: nextZIndex }); }} onSendToBack={() => { const nextZIndex = - imageLayerBounds.min - 1; + Math.min(0, ...elements.map((candidate) => candidate.zIndex ?? 0)) - 1; onUpdateInsertedElement(pageIndex, el.id, { zIndex: nextZIndex }); }} onRemove={() => { @@ -237,13 +232,12 @@ export function OverlayLayer({ return ( @@ -258,7 +252,7 @@ export function OverlayLayer({ }) } onCommit={(patch) => { - onUpdateTextBox(pageIndex, tb.id, patchForOwner(patch, pageIndex, pageHeight)); + onUpdateTextBox(pageIndex, tb.id, patch); if (patch.isEditing === false) { onSelectionChange({ type: 'text', pageIndex, id: tb.id }); } @@ -270,9 +264,16 @@ export function OverlayLayer({ /> ); })} - + ); })} ); } + +const styles = StyleSheet.create({ + pageHost: { + position: 'absolute', + overflow: 'hidden', + }, +}); diff --git a/src/components/editor/README.md b/src/components/editor/README.md deleted file mode 100644 index 6d83eb8..0000000 --- a/src/components/editor/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 in the ongoing 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. diff --git a/src/components/editor/TextBoxOverlay.tsx b/src/components/editor/TextBoxOverlay.tsx index faa9bc2..887a696 100644 --- a/src/components/editor/TextBoxOverlay.tsx +++ b/src/components/editor/TextBoxOverlay.tsx @@ -1,4 +1,3 @@ -import {useNativeCallback} from '../../hooks/native-callback'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Platform, @@ -19,7 +18,6 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue, - type SharedValue, } from 'react-native-reanimated'; import type { InkTextBox } from '@mathnotes/mobile-ink'; import { useTheme } from '../../hooks/useTheme'; @@ -45,7 +43,6 @@ type ResizeHandle = export interface TextBoxOverlayProps { textBox: InkTextBox; pageScale: number; - gestureScale?: SharedValue; screenLeft: number; screenTop: number; pageWidth: number; @@ -98,7 +95,9 @@ 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 }; } @@ -106,7 +105,6 @@ function resizeFromHandle( export function TextBoxOverlay({ textBox, pageScale, - gestureScale, screenLeft, screenTop, pageWidth, @@ -178,7 +176,7 @@ export function TextBoxOverlay({ y, ]); - const commitLayout = useNativeCallback( + const commitLayout = useCallback( (nextX: number, nextY: number, nextWidth: number, nextHeight: number) => { onCommit({ x: nextX, @@ -187,14 +185,15 @@ export function TextBoxOverlay({ height: nextHeight, }); }, + [onCommit], ); - const selectFromFinger = useNativeCallback(() => { + const selectFromFinger = useCallback(() => { if (!isSelectedRef.current) { suppressEditUntilRef.current = Date.now() + 250; } onSelect(); - }); + }, [onSelect]); const handleFingerPointerDown = useCallback((event: any) => { const pointerType = event.nativeEvent.pointerType; @@ -203,7 +202,7 @@ export function TextBoxOverlay({ selectFromFinger(); }, [isEditing, selectFromFinger]); - const handleTap = useNativeCallback(() => { + const handleTap = useCallback(() => { if (isSelectedRef.current) { if (Date.now() < suppressEditUntilRef.current) return; onStartEdit(); @@ -211,7 +210,7 @@ export function TextBoxOverlay({ suppressEditUntilRef.current = Date.now() + 250; onSelect(); } - }); + }, [onSelect, onStartEdit]); const handleContentChange = useCallback( (content: string) => { @@ -221,21 +220,16 @@ export function TextBoxOverlay({ [onCommit], ); - const dragOriginX = useSharedValue(0); - const dragOriginY = useSharedValue(0); - const dragGesture = Gesture.Pan().maxPointers(1) + const dragGesture = Gesture.Pan() .enabled(isSelected && !isEditing) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { 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) => { @@ -252,21 +246,20 @@ export function TextBoxOverlay({ .onUpdate((evt) => { 'worklet'; x.value = clamp( - savedX.value + (evt.absoluteX - dragOriginX.value) / (gestureScale?.value ?? pageScale), + savedX.value + evt.translationX / pageScale, 0, Math.max(0, pageWidth - width.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);} + y.value = clamp( + savedY.value + evt.translationY / pageScale, + 0, + Math.max(0, pageHeight - height.value), + ); }) - .onFinalize((_event, success) => { + .onEnd(() => { '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() @@ -275,7 +268,7 @@ export function TextBoxOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); } }) @@ -284,20 +277,16 @@ export function TextBoxOverlay({ if (success) runOnJS(handleTap)(); }); - const resizeOriginX = useSharedValue(0); - const resizeOriginY = useSharedValue(0); const createResizeGesture = (handle: ResizeHandle) => - Gesture.Pan().maxPointers(1) + Gesture.Pan() .enabled(isSelected && !isEditing) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } - const touch = event.allTouches[0]; - if (touch) {resizeOriginX.value = touch.absoluteX; resizeOriginY.value = touch.absoluteY;} stateManager.activate(); }) .onStart(() => { @@ -316,8 +305,8 @@ export function TextBoxOverlay({ savedY.value, savedWidth.value, savedHeight.value, - (evt.absoluteX - resizeOriginX.value) / (gestureScale?.value ?? pageScale), - (evt.absoluteY - resizeOriginY.value) / (gestureScale?.value ?? pageScale), + evt.translationX / pageScale, + evt.translationY / pageScale, pageWidth, pageHeight, ); @@ -326,11 +315,10 @@ export function TextBoxOverlay({ width.value = next.width; height.value = next.height; }) - .onEnd((_event, success) => { + .onEnd(() => { 'worklet'; isResizing.value = false; - 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; } + runOnJS(commitLayout)(x.value, y.value, width.value, height.value); }); const animatedStyle = useAnimatedStyle(() => ({ @@ -344,6 +332,7 @@ export function TextBoxOverlay({ transform: [ { translateX: x.value }, { translateY: y.value }, + { scale: isDragging.value || isResizing.value ? 1.02 : 1 }, ], })); @@ -354,8 +343,6 @@ 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 deleted file mode 100644 index 6a8a08c..0000000 --- a/src/components/editor/history/transactions.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 70ceecc..0000000 --- a/src/components/editor/history/useOverlayHistory.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 85e0b59..0000000 --- a/src/components/editor/overlayGeometry.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 dd44e4a..c9acb9c 100644 --- a/src/components/library/NoteCard.tsx +++ b/src/components/library/NoteCard.tsx @@ -20,7 +20,6 @@ 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 deleted file mode 100644 index 0b2f1e7..0000000 --- a/src/hooks/native-callback/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index 522518e..0000000 --- a/src/hooks/native-callback/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 433c08c..89efe70 100644 --- a/src/hooks/useAutosave.ts +++ b/src/hooks/useAutosave.ts @@ -1,12 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { AppState } from 'react-native'; -import { isDocumentCaptureSuperseded } from '@mathnotes/mobile-ink'; -import { createAutosaveQueue, type AutosaveStatus } from './autosaveQueue'; - -export type { AutosaveStatus } from './autosaveQueue'; const DEFAULT_DEBOUNCE_MS = 350; +export type AutosaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; + export interface UseAutosaveOptions { onSave: () => Promise; onStatusChange?: (status: AutosaveStatus) => void; @@ -20,21 +18,79 @@ 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); - 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, - })); + + 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]); // 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 @@ -42,13 +98,20 @@ export function useAutosave({ useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state === 'background' || state === 'inactive') { - void queue.flushNow(); + void flushNow(); } }); return () => subscription.remove(); - }, [queue]); + }, [flushNow]); - useEffect(() => () => queue.dispose(), [queue]); + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, []); - return { schedule: queue.schedule, flushNow: queue.flushNow, cancelPending: queue.cancelPending }; + return { schedule, flushNow, cancelPending }; } diff --git a/src/hooks/useViewportTransform.ts b/src/hooks/useViewportTransform.ts index 19c3bb4..720a093 100644 --- a/src/hooks/useViewportTransform.ts +++ b/src/hooks/useViewportTransform.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useSyncExternalStore } from 'react'; import type { InfiniteInkViewportTransform } from '@mathnotes/mobile-ink'; export interface ViewportTransformStore { @@ -29,17 +29,21 @@ export function createViewportTransformStore(): ViewportTransformStore { return { transform: current, onTransformChange, subscribe, getSnapshot }; } -/** - * 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 } { +export function useViewportTransformStore(): { + store: ViewportTransformStore; + transform: InfiniteInkViewportTransform | null; +} { const storeRef = useRef(null); if (!storeRef.current) { storeRef.current = createViewportTransformStore(); } - return { store: storeRef.current }; + const store = storeRef.current; + const transform = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + () => null, + ); + return { store, transform }; } export interface PageRect { diff --git a/src/services/exportService.ts b/src/services/exportService.ts index d34113e..3a6a5a7 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, projectDocumentPages, type SerializedNotebookData } from '@mathnotes/mobile-ink'; +import { batchExportPages, type SerializedNotebookData } from '@mathnotes/mobile-ink'; import { buildPdfHtml, NOTE_PAGE_HEIGHT, @@ -12,10 +12,11 @@ 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 @@ -42,6 +43,22 @@ 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) { @@ -79,14 +96,13 @@ async function embedInsertedImage(uri: string): Promise { } async function preparePrintablePages( - indexedAssets: ReturnType, + notebookPages: SerializedNotebookData['pages'], rasterUris: string[], - pageIndexes: number[], ): Promise { const cachedImages = new Map(); const pages: PrintablePage[] = []; - for (let index = 0; index < pageIndexes.length; index += 1) { - const page = indexedAssets[pageIndexes[index]]; + for (let index = 0; index < notebookPages.length; index += 1) { + const page = notebookPages[index]; const images: PrintablePage['images'] = []; for (const element of page.insertedElements ?? []) { const sourceUri = element.sourceUri ?? element.renderedImageUri; @@ -131,13 +147,8 @@ 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( - inkPayloads, + chunkPages.map((p) => p?.data ?? '{"pages":{}}'), chunkPages.map((p) => p?.pageType ?? 'plain'), exportWidth, exportHeight, @@ -171,17 +182,40 @@ async function collectExportImages(options: { pdfBackgroundUri?: string | null; }): Promise { const { data, pageIndexes, exportWidth, exportHeight, pdfBackgroundUri } = options; - // 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; - }); + 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); } async function mergePrintedPasses(passUris: string[]): Promise { @@ -217,7 +251,6 @@ 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( @@ -239,9 +272,8 @@ export async function exportNotebookAsPdf( } const printablePages = await preparePrintablePages( - indexedAssets, + pageIndexes.map((pageIndex) => data.pages[pageIndex]), rasterUris, - pageIndexes, ); const printed = await Print.printToFileAsync({ html: buildPdfHtml(printablePages), diff --git a/src/utils/continuousAssets.ts b/src/utils/continuousAssets.ts deleted file mode 100644 index b47d8ec..0000000 --- a/src/utils/continuousAssets.ts +++ /dev/null @@ -1,73 +0,0 @@ -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, - }; -}