diff --git a/app.json b/app.json index d26bb2e..80150c3 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "OpenNotes", "slug": "open-notes", - "version": "1.3.3", + "version": "1.3.4", "scheme": "opennotes", "orientation": "default", "icon": "./assets/icon.png", @@ -16,7 +16,7 @@ "ios": { "bundleIdentifier": "com.builderpro.opennotes", "icon": "./assets/icon.png", - "buildNumber": "16", + "buildNumber": "17", "supportsTablet": true, "usesIcloudStorage": true, "entitlements": { diff --git a/app/note/[id].tsx b/app/note/[id].tsx index de495e3..b5274b7 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -1,3 +1,4 @@ +import {useOverlayHistory} from '../../src/components/editor/history/useOverlayHistory'; import React, { useCallback, useEffect, @@ -23,8 +24,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { - InfiniteInkCanvas, - type InfiniteInkCanvasRef, + ContinuousDocumentCanvas as InfiniteInkCanvas, + type ContinuousDocumentCanvasRef, type InkTextBox, type InsertedElement, type NotebookPage, @@ -61,6 +62,7 @@ import { } from '../../src/services/imageInsertStorage'; import { exportNotebookAsPdf } from '../../src/services/exportService'; import { recordSuccessfulNoteSave } from '../../src/services/lifecycleService'; +import { duplicateAssetPosition } from '../../src/utils/continuousAssets'; import { textBoxId, insertedElementId } from '../../src/utils/id'; import { spacing } from '../../src/theme/spacing'; import { t } from '../../src/i18n'; @@ -135,7 +137,7 @@ export default function NoteScreen() { const { width: viewportWidth, height: viewportHeight } = useWindowDimensions(); const { id } = useLocalSearchParams<{ id: string }>(); - const canvasRef = useRef(null); + const canvasRef = useRef(null); const pendingBodyRef = useRef(null); const canvasReadyRef = useRef(false); const isMountedRef = useRef(true); @@ -148,13 +150,20 @@ export default function NoteScreen() { const [metadata, setMetadata] = useState(null); const [enginePages, setEnginePages] = useState([]); + const enginePagesRef = useRef(enginePages); + const updateEnginePages = useCallback((pages: NotebookPage[]) => { + enginePagesRef.current = pages; + setEnginePages(pages); + }, []); + const flushHistoryRef = useRef<() => Promise>(async () => {}); const [overlayMap, setOverlayMap] = useState>( () => new Map(), ); const overlayMapRef = useRef(overlayMap); - useEffect(() => { - overlayMapRef.current = overlayMap; - }, [overlayMap]); + const replaceOverlayMap = useCallback((next: Map) => { + overlayMapRef.current = next; + setOverlayMap(next); + }, []); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [autosaveStatus, setAutosaveStatus] = useState('idle'); @@ -203,6 +212,7 @@ export default function NoteScreen() { const persistMerged = useCallback(async () => { if (!id || !canvasRef.current) return; + await flushHistoryRef.current(); const canvasData = await canvasRef.current.getNotebookData(); const overlay = overlayMapRef.current; const mergedPages = mergeStoredPreviews(canvasData.pages).map((page) => { @@ -237,6 +247,13 @@ export default function NoteScreen() { enabled: Boolean(id) && autosaveEnabled && bodyReady, }); + const history = useOverlayHistory({fileId: id, canvasRef, + readPages: () => enginePagesRef.current.map(page => ({...page, ...overlayMapRef.current.get(page.id)})), + writePages: pages => replaceOverlayMap(new Map(pages.map(page => [page.id, overlayFromPage(page)]))), + onChange: scheduleAutosave, + }); + flushHistoryRef.current = history.flushOverlayHistory; + useEffect(() => { isMountedRef.current = true; return () => { @@ -276,23 +293,32 @@ 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); - setEnginePages(mergeStoredPreviews(notebookData.pages)); + updateEnginePages(mergeStoredPreviews(notebookData.pages)); const initialMap = new Map(); for (const page of notebookData.pages) { initialMap.set(page.id, overlayFromPage(page)); } - setOverlayMap(initialMap); + replaceOverlayMap(initialMap); setBodyReady(true); - if (canvasReadyRef.current) { - void canvasRef.current?.loadNotebookData(notebookData); + if (canvasReadyRef.current && canvasRef.current) { + void canvasRef.current.loadNotebookData(notebookData).catch(handleCanvasLoadError); } else { pendingBodyRef.current = notebookData; } }, - [mergeStoredPreviews, rememberPagePreviews], + [mergeStoredPreviews, rememberPagePreviews, handleCanvasLoadError], ); // Initial load @@ -302,7 +328,7 @@ export default function NoteScreen() { storedPreviewByPageIdRef.current = new Map(); setBodyReady(false); setBodyLoadFailed(false); - setEnginePages([]); + updateEnginePages([]); setCurrentPageIndex(0); setLoading(true); (async () => { @@ -357,17 +383,18 @@ export default function NoteScreen() { const pending = pendingBodyRef.current; if (pending) { pendingBodyRef.current = null; - void canvasRef.current?.loadNotebookData(pending); + void canvasRef.current?.loadNotebookData(pending).catch(handleCanvasLoadError); } - }, []); + }, [handleCanvasLoadError]); const handlePagesChange = useCallback((next: NotebookPage[]) => { if (!isMountedRef.current || exitRef.current === 'navigating') return; rememberPagePreviews(next); const pagesWithPreviews = mergeStoredPreviews(next); - setEnginePages(pagesWithPreviews); + updateEnginePages(pagesWithPreviews); // Make sure every page id has an overlay slot; drop overlays for removed pages. - setOverlayMap((prev) => { + { + const prev = overlayMapRef.current; let changed = false; const nextMap = new Map(prev); const idsInPages = new Set(pagesWithPreviews.map((p) => p.id)); @@ -383,8 +410,8 @@ export default function NoteScreen() { changed = true; } } - return changed ? nextMap : prev; - }); + if (changed) replaceOverlayMap(nextMap); + } }, [mergeStoredPreviews, rememberPagePreviews]); const handleDrawingChange = useCallback(() => { @@ -459,13 +486,13 @@ export default function NoteScreen() { ); const handleUndo = useCallback(() => { - canvasRef.current?.undo(); + history.undoHistory(); scheduleAutosave(); - }, [scheduleAutosave]); + }, [scheduleAutosave, history.undoHistory]); const handleRedo = useCallback(() => { - canvasRef.current?.redo(); + history.redoHistory(); scheduleAutosave(); - }, [scheduleAutosave]); + }, [scheduleAutosave, history.redoHistory]); const handleJumpToPage = useCallback( (pageIndex: number) => { @@ -496,18 +523,13 @@ export default function NoteScreen() { pageIndex: number, updater: (state: PageOverlayState) => PageOverlayState, ) => { - setOverlayMap((prev) => { - const page = enginePages[pageIndex]; - if (!page) return prev; - const current = prev.get(page.id) ?? emptyOverlay(); - const next = updater(current); - const nextMap = new Map(prev); - nextMap.set(page.id, next); - return nextMap; - }); + const pageId = enginePagesRef.current[pageIndex]?.id; + if (!pageId) return; + history.setOverlayPages(pages => pages.map(page => page.id === pageId + ? {...page, ...updater(overlayFromPage(page))} : page)); scheduleAutosave(); }, - [enginePages, scheduleAutosave], + [history.setOverlayPages, scheduleAutosave], ); const applyInsertedImage = useCallback( @@ -644,12 +666,15 @@ export default function NoteScreen() { const now = new Date().toISOString(); const nextId = insertedElementId(); - const maxZIndex = Math.max(0, ...state.insertedElements.map((el) => el.zIndex ?? 0)); + const maxZIndex = Array.from(overlayMapRef.current.values()).reduce( + (highest, overlay) => overlay.insertedElements.reduce( + (value, asset) => Math.max(value, asset.zIndex ?? 0), highest, + ), 0, + ); const duplicated: InsertedElement = { ...element, id: nextId, - x: Math.max(0, Math.min(PAGE_WIDTH - (element.width ?? 80), element.x + 20)), - y: Math.max(0, Math.min(PAGE_HEIGHT - (element.height ?? 80), element.y + 20)), + ...duplicateAssetPosition(element, pageIndex, PAGE_WIDTH, PAGE_HEIGHT, enginePages.length), zIndex: maxZIndex + 1, createdAt: now, updatedAt: now, @@ -696,6 +721,7 @@ export default function NoteScreen() { setIsExporting(true); try { await flushNow(); + await flushHistoryRef.current(); const canvasData = await canvasRef.current.getNotebookData(); const overlay = overlayMapRef.current; const mergedPages = mergeStoredPreviews(canvasData.pages).map((page) => { @@ -908,7 +934,10 @@ export default function NoteScreen() { pdfBackgroundBaseUri={metadata?.pdfUri ?? undefined} fingerDrawingEnabled={fingerDrawingEnabled} onReady={handleCanvasReady} + onError={handleCanvasLoadError} onDrawingChange={handleDrawingChange} + onExternalEdit={payload => history.handleHistoryDrawingChange({nativeEvent: {externalEdit: {payload}}})} + onHistoryAction={historyAction => history.handleHistoryDrawingChange({nativeEvent: {historyAction}})} onCurrentPageChange={handleCurrentPageChange} onPagesChange={handlePagesChange} onPencilDoubleTap={handlePencilDoubleTap} diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index 7ffe5e5..993b72b 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -11,29 +11,46 @@ 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 = ""; }; @@ -49,9 +66,25 @@ ); 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 = ( @@ -75,6 +108,7 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, A7A85B485F4321855B927960 /* libPods-OpenNotes.a */, + 113AAC429982245F287A514B /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -103,6 +137,7 @@ 2D16E6871FA4F8E400B85C8A /* Frameworks */, CCEB29EB4EA264547E3F22DF /* Pods */, B025AFE641A7B146AB64069B /* ExpoModulesProviders */, + 94F4EBFAAF2787ED7D01F434 /* OpenNotesUITests */, ); indentWidth = 2; sourceTree = ""; @@ -113,10 +148,21 @@ 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 = ( @@ -169,6 +215,24 @@ 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 */ @@ -198,11 +262,19 @@ 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; @@ -352,8 +424,26 @@ ); 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; @@ -364,7 +454,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -377,7 +467,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -403,7 +493,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -411,7 +501,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -545,6 +635,41 @@ }; 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 */ @@ -557,6 +682,15 @@ 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 new file mode 100644 index 0000000..e473a03 --- /dev/null +++ b/ios/OpenNotes.xcodeproj/xcshareddata/xcschemes/OpenNotesIntegrity.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index c3a1472..513a139 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -34,7 +34,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.3.3 + 1.3.4 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 16 + 17 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/ios/OpenNotesUITests/InkVisibilityAssertions.swift b/ios/OpenNotesUITests/InkVisibilityAssertions.swift new file mode 100644 index 0000000..c1ec9e1 --- /dev/null +++ b/ios/OpenNotesUITests/InkVisibilityAssertions.swift @@ -0,0 +1,72 @@ +import XCTest +import UIKit + +/// The dense fixture uses dark ink on a light page. Sample only page content, +/// excluding the navigation/toolbars, page edges, and floating AI control. +private func visibleInkSamples(_ image: UIImage) -> Int { + guard let source = image.cgImage else { return 0 } + let width = source.width, height = source.height + var pixels = [UInt8](repeating: 255, count: width * height * 4) + return pixels.withUnsafeMutableBytes { bytes in + guard let context = CGContext(data: bytes.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) + else { return 0 } + context.draw(source, in: CGRect(x: 0, y: 0, width: width, height: height)) + let data = bytes.bindMemory(to: UInt8.self) + var count = 0 + for y in (height / 5)..<(height * 2 / 3) { + for x in (width / 100)..<(width * 85 / 100) { + let offset = (y * width + x) * 4 + if data[offset] < 90 && data[offset + 1] < 90 && data[offset + 2] < 110 { count += 1 } + } + } + return count + } +} + +func assertDenseInkVisible(_ app: XCUIApplication, context: String, + file: StaticString = #filePath, line: UInt = #line) { + let predicate = NSPredicate { _, _ in visibleInkSamples(app.screenshot().image) > 100 } + let result = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: predicate, object: nil)], timeout: 8) + if result != .completed { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Missing dense ink — \(context)" + attachment.lifetime = .keepAlways + XCTContext.runActivity(named: context) { $0.add(attachment) } + } + XCTAssertEqual(result, .completed, "Expected visible dense ink: \(context)", file: file, line: line) +} + +/// Count newly darkened pixels in a screen-coordinate rectangle, excluding +/// preexisting fixture ink. Both captures use the same transform and viewport. +func changedInkPixels(_ before: UIImage, _ after: UIImage, rect: CGRect) -> Int { + guard let first = before.cgImage, let second = after.cgImage, + first.width == second.width, first.height == second.height else { return 0 } + let width = first.width, height = first.height + func pixels(_ image: CGImage) -> [UInt8] { + var data = [UInt8](repeating: 255, count: width * height * 4) + data.withUnsafeMutableBytes { bytes in + let context = CGContext(data: bytes.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)! + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return data + } + let a = pixels(first), b = pixels(second) + let scale = CGFloat(width) / after.size.width + let area = rect.applying(CGAffineTransform(scaleX: scale, y: scale)) + .intersection(CGRect(x: 0, y: 0, width: width, height: height)) + guard !area.isNull else { return 0 } + var changed = 0 + for y in Int(area.minY).. 100 { + changed += 1 + } + } + } + return changed +} diff --git a/ios/OpenNotesUITests/NotebookInteractionUITests.swift b/ios/OpenNotesUITests/NotebookInteractionUITests.swift new file mode 100644 index 0000000..625f45b --- /dev/null +++ b/ios/OpenNotesUITests/NotebookInteractionUITests.swift @@ -0,0 +1,426 @@ +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") + } + } + } + + 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 new file mode 100644 index 0000000..460a492 --- /dev/null +++ b/ios/OpenNotesUITests/README.md @@ -0,0 +1,34 @@ +# 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 03d4228..22086ed 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -259,7 +259,7 @@ PODS: - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) - hermes-engine/Pre-built (0.81.5) - - MathNotesMobileInk (0.3.6): + - MathNotesMobileInk (0.3.8): - React-Core - react-native-skia - RCTDeprecation (0.81.5) @@ -2552,7 +2552,7 @@ SPEC CHECKSUMS: EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 - MathNotesMobileInk: affc434b8ea8baee9686500bcc89fa5c40ac861c + MathNotesMobileInk: 84fb87a4ff80b818202db8b5fbb7ac8bbdcd419c RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c diff --git a/package-lock.json b/package-lock.json index a46c8b0..d22ef72 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.6", + "@mathnotes/mobile-ink": "0.3.8", "@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.6", - "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.6.tgz", - "integrity": "sha512-XrRL8JVEoG1YuyiRCiP+jvVpD6fP/LhAQKDddQc2tTf3ln6Wuz/w3/T437IgrOhrEk6+uWz9IcC164PqtMqHEA==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.8.tgz", + "integrity": "sha512-cmeSgqHb8TLoJnqbzg6KkCWV75zBM7W+AVT2K2kjNnldXgeJY8FKmpAzAQqTbvAA0gpMI33hchBPqtLly6O0Rg==", "license": "Apache-2.0", "peerDependencies": { "@shopify/react-native-skia": ">=2.0.0", diff --git a/package.json b/package.json index 883fe2a..05429a7 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", + "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", "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.6", + "@mathnotes/mobile-ink": "0.3.8", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/react-native-skia": "2.2.12", "expo": "54.0.34", diff --git a/scripts/continuousAssets.test.mjs b/scripts/continuousAssets.test.mjs new file mode 100644 index 0000000..e539bea --- /dev/null +++ b/scripts/continuousAssets.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { assetInNotebook, patchForOwner, assetsForPage, indexAssetsByPage, duplicateAssetPosition } from '../src/utils/continuousAssets.ts'; + +test('moving an asset across its owner boundary preserves its identity and local storage', () => { + const original = { id: 'image', y: 950, height: 200 }; + const view = assetInNotebook(original, 2, 1000); + assert.equal(view.y, 2950); + const patch = patchForOwner({ y: 1850, width: 120 }, 2, 1000); + assert.deepEqual(patch, { y: -150, width: 120 }); + assert.equal(assetInNotebook({ ...original, ...patch }, 2, 1000).y, 1850); + assert.equal(original.y, 950); + assert.deepEqual(patchForOwner({ width: 40 }, 2, 1000), { width: 40 }); +}); + +test('a crossing image projects into both pages without changing the saved object', () => { + const image = { id: 'a', x: 50, y: 900, width: 100, height: 200, type: 'image' }; + const pages = [{ insertedElements: [image] }, {}, {}]; + assert.equal(assetsForPage(pages, 0, 1000).insertedElements[0].y, 900); + assert.equal(assetsForPage(pages, 1, 1000).insertedElements[0].y, -100); + assert.equal(assetsForPage(pages, 2, 1000).insertedElements.length, 0); + assert.equal(image.y, 900); + assert.equal(pages[1].insertedElements, undefined); +}); + +test('rotation can cross a boundary even when the unrotated rectangle does not', () => { + const pages = [{ insertedElements: [{ id: 'r', y: 880, width: 400, height: 80, rotation: 90 }] }, {}]; + assert.equal(assetsForPage(pages, 1, 1000).insertedElements.length, 1); +}); + +test('text moved above its owner exports on the earlier page and across its seam', () => { + const box = { id: 't', y: -100, height: 200, width: 100, content: 'hello' }; + const pages = [{}, { textBoxes: [box] }]; + assert.equal(assetsForPage(pages, 0, 1000).textBoxes[0].y, 900); + assert.equal(assetsForPage(pages, 1, 1000).textBoxes[0].y, -100); +}); + + +test('large export indexing visits each asset once and preserves exact seam boundaries', () => { + let widthReads = 0; + const pages = Array.from({ length: 1000 }, (_, i) => ({ insertedElements: [{ + id: String(i), y: 900, height: 100, + get width() { widthReads++; return 100; }, + }] })); + const indexed = indexAssetsByPage(pages, 1000); + assert.equal(indexed.length, 1000); + assert.ok(widthReads <= 4000, 'indexing must not revisit all assets for each page'); + assert.ok(indexed.every((page) => page.insertedElements.length === 1)); + assert.equal(indexed[999].insertedElements[0].id, '999'); +}); + +test('invalid geometry fails explicitly instead of producing an incomplete PDF', () => { + assert.throws(() => indexAssetsByPage([{ insertedElements: [{ y: NaN }] }], 1000), /invalid geometry/); +}); + + +test('duplication keeps cross-page and negative-owner positions, bounded by the notebook', () => { + assert.deepEqual(duplicateAssetPosition({ x: 20, y: 950, width: 100, height: 200 }, 0, 600, 1000, 3), { x: 40, y: 970 }); + assert.deepEqual(duplicateAssetPosition({ x: 20, y: -150, width: 100, height: 200 }, 2, 600, 1000, 3), { x: 40, y: -130 }); + assert.deepEqual(duplicateAssetPosition({ x: 550, y: 900, width: 100, height: 200 }, 2, 600, 1000, 3), { x: 500, y: 800 }); +}); diff --git a/scripts/exportService.test.mjs b/scripts/exportService.test.mjs index fbfab44..f949f23 100644 --- a/scripts/exportService.test.mjs +++ b/scripts/exportService.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; +import { indexAssetsByPage } from '../src/utils/continuousAssets.ts'; import { buildPdfHtml } from '../src/services/pdfExportHtml.ts'; const source = ts.transpileModule( @@ -27,6 +28,7 @@ function notebook(pages) { function harness(files = {}, { mergePdfFiles } = {}) { const printCalls = []; const batchCalls = []; + const projectionCalls = []; const fileReads = []; const deletedFiles = []; const mergeCalls = []; @@ -49,6 +51,10 @@ function harness(files = {}, { mergePdfFiles } = {}) { }, }; const mobileInk = { + async projectDocumentPages(payloads, height, indices) { + projectionCalls.push({ payloads, height, indices }); + return indices.map(index => 'projected-' + index); + }, async batchExportPages(...args) { batchCalls.push(args); return args[0].map((_, index) => 'data:image/png;base64,' + (index + 1)); @@ -77,6 +83,7 @@ function harness(files = {}, { mergePdfFiles } = {}) { }; } if (name === '@mathnotes/mobile-ink') return mobileInk; + if (name === '../utils/continuousAssets') return { indexAssetsByPage }; if (name === './pdfExportHtml') { return { buildPdfHtml, @@ -93,6 +100,7 @@ function harness(files = {}, { mergePdfFiles } = {}) { exportNotebookAsPdf: exports.exportNotebookAsPdf, printCalls, batchCalls, + projectionCalls, fileReads, deletedFiles, mergeCalls, @@ -256,3 +264,43 @@ test('base64 page rasters are embedded verbatim instead of escaped copies', () = const unsafe = buildPdfHtml([{ rasterUri: 'file:///a"b.png', images: [], textBoxes: [] }]); assert.ok(unsafe.includes('file:///a"b.png')); }); + +test('cross-page assets survive the boundary between PDF print passes', async () => { + const photo = 'file:///notes/crossing.png'; + const h = harness({ [photo]: 'cGhvdG8=' }); + const data = notebook(Array.from({ length: 9 }, () => ({}))); + data.pages[7].insertedElements = [{ + id: 'crossing', type: 'image', sourceUri: photo, + x: 40, y: 1000, width: 200, height: 200, + }]; + data.pages[7].textBoxes = [{ + id: 'text', content: 'Across the seam', x: 20, y: 1030, + width: 200, height: 120, fontSize: 18, color: '#000000', + }]; + const original = JSON.stringify(data); + const result = await h.exportNotebookAsPdf({ data, filename: 'crossing' }); + assert.equal(result.ok, true); + assert.equal(h.printCalls.length, 2); + assert.match(h.printCalls[0].html, /top:1000px;width:200px/); + assert.match(h.printCalls[1].html, /top:-61px;width:200px/); + assert.match(h.printCalls[1].html, /top:-31px;width:200px/); + assert.match(h.printCalls[1].html, /Across the seam/); + assert.equal(JSON.stringify(data), original); +}); + + +test('each bounded export batch projects from the entire notebook, ignoring stale previews', async () => { + const h = harness(); + const data = notebook(Array.from({ length: 9 }, (_, index) => ({ + data: 'owner-' + index, previewUri: 'data:image/png;base64,stale', + }))); + const result = await h.exportNotebookAsPdf({ data, filename: 'cross-page' }); + assert.equal(result.ok, true); + assert.equal(h.projectionCalls.length, 2); + for (const call of h.projectionCalls) { + assert.equal(call.payloads.length, 9); + assert.equal(call.height, 2122); + } + assert.deepEqual(Array.from(h.projectionCalls[1].indices), [8]); + assert.deepEqual(Array.from(h.batchCalls[1][0]), ['projected-8']); +}); diff --git a/scripts/nativeCallback.test.mjs b/scripts/nativeCallback.test.mjs new file mode 100644 index 0000000..c42f900 --- /dev/null +++ b/scripts/nativeCallback.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createNativeCallback} from '../src/hooks/native-callback/index.ts'; + +test('retained native dispatchers use the latest consumer and release closed editors', () => { + const bridge = createNativeCallback(); + const retained = bridge.invoke; + const first = [], second = []; + bridge.update(value => first.push(value)); + retained(1); + bridge.update(value => second.push(value)); + retained(2); + bridge.update(null); + retained(3); + assert.deepEqual(first, [1]); + assert.deepEqual(second, [2]); +}); + +test('callback failures propagate rather than being silently ignored', () => { + const bridge = createNativeCallback(); + bridge.update(() => { throw new Error('save failed'); }); + assert.throws(() => bridge.invoke(), /save failed/); +}); diff --git a/scripts/overlayGeometry.test.mjs b/scripts/overlayGeometry.test.mjs new file mode 100644 index 0000000..fcd41cb --- /dev/null +++ b/scripts/overlayGeometry.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {resizeFromCorner} from '../src/components/editor/overlayGeometry.ts'; + +test('resizing overflow retains its owner-relative negative top', () => { + assert.deepEqual(resizeFromCorner('bottom-right', 280, -70, 180, 140, 25, 20, 820, false), + {x: 280, y: -70, width: 205, height: 160}); +}); +test('top-left resizing preserves opposite corner across a page break', () => { + const next = resizeFromCorner('top-left', 280, -70, 180, 140, -20, -30, 820, false); + assert.deepEqual(next, {x: 260, y: -100, width: 200, height: 170}); + assert.equal(next.y + next.height, 70); +}); +test('aspect-ratio resize can span more than one page', () => { + const next = resizeFromCorner('bottom-right', 0, -100, 400, 1000, 400, 1000, 820, true); + assert.equal(next.y, -100); + assert.equal(next.width, 800); + assert.equal(next.height, 2000); +}); +test('minimum size and horizontal boundary remain enforced', () => { + const next = resizeFromCorner('top-left', 280, -70, 180, 140, 1000, 1000, 820, false); + assert.equal(next.width, 50); assert.equal(next.height, 50); + assert.equal(next.x + next.width, 460); assert.equal(next.y + next.height, 70); +}); + +import {localPointerDelta, anchorRotatedFrame, cropFromEdge} from '../src/components/editor/overlayGeometry.ts'; +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`); +function corner(frame, u, v, degrees) { + const angle = degrees * Math.PI / 180; + const x = (u - .5) * frame.width, y = (v - .5) * frame.height; + return {x: frame.x + frame.width / 2 + x * Math.cos(angle) - y * Math.sin(angle), + y: frame.y + frame.height / 2 + x * Math.sin(angle) + y * Math.cos(angle)}; +} +for (const angle of [0, 45, 90, 180, 270]) { + test(`rotated resize at ${angle} degrees fixes the opposite corner`, () => { + const before = {x: 280, y: -70, width: 180, height: 140}; + for (const handle of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { + const dx = handle.includes('left') ? -25 : 25, dy = handle.includes('top') ? -20 : 20; + const radians = angle * Math.PI / 180; + const delta = localPointerDelta((dx * Math.cos(radians) - dy * Math.sin(radians)) * 2, + (dx * Math.sin(radians) + dy * Math.cos(radians)) * 2, 2, angle); + near(delta.x, dx); near(delta.y, dy); + const local = resizeFromCorner(handle, before.x, before.y, before.width, before.height, delta.x, delta.y, 820, false); + const next = anchorRotatedFrame(before, local, angle); + const u = handle.includes('left') ? 1 : 0, v = handle.includes('top') ? 1 : 0; + const oldCorner = corner(before, u, v, angle), newCorner = corner(next, u, v, angle); + near(oldCorner.x, newCorner.x); near(oldCorner.y, newCorner.y); + near(next.width, 205); near(next.height, 160); + } + }); + test(`crop and uncrop at ${angle} degrees preserve image alignment`, () => { + const before = {x: 280, y: -70, width: 180, height: 140}; + const cropped = cropFromEdge(before, {x: 0, y: 0, width: 1, height: 1}, 'top-left', 25, 20, angle); + const oldCorner = corner(before, 1, 1, angle), newCorner = corner(cropped.frame, 1, 1, angle); + near(oldCorner.x, newCorner.x); near(oldCorner.y, newCorner.y); + near(cropped.crop.width * 180, cropped.frame.width); + near(cropped.crop.height * 140, cropped.frame.height); + const restored = cropFromEdge(cropped.frame, cropped.crop, 'top-left', -1000, -1000, angle); + for (const key of ['x', 'y', 'width', 'height']) near(restored.frame[key], before[key]); + near(restored.crop.x, 0); near(restored.crop.y, 0); + near(restored.crop.width, 1); near(restored.crop.height, 1); + }); +} +test('crop minimum stays synchronized with normalized crop for large assets', () => { + const next = cropFromEdge({x: 0, y: -20, width: 4000, height: 3000}, + {x: 0, y: 0, width: 1, height: 1}, 'bottom-right', -10000, -10000, 0); + near(next.crop.width, .02); near(next.crop.height, .02); + near(next.frame.width, 80); near(next.frame.height, 60); +}); diff --git a/scripts/overlayHistory.test.mjs b/scripts/overlayHistory.test.mjs new file mode 100644 index 0000000..6baf834 --- /dev/null +++ b/scripts/overlayHistory.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {overlayPatches, applyOverlayPatch} from '../src/components/editor/history/patch.ts'; +import {createOverlayTransactions} from '../src/components/editor/history/transactions.ts'; +const fixture = () => [{id: 'page', title: 'Page', rotation: 0, data: 'ink', textBoxes: [ + {id: 'text', x: 0, y: -30, width: 100, content: 'Note'}]}]; +const move = y => pages => pages.map(page => ({...page, textBoxes: page.textBoxes.map(box => ({...box, y}))})); + +test('undo/redo across a page boundary does not revert newly captured ink', () => { + const before = fixture(), after = move(90)(before); + const patch = overlayPatches(before, after); + const newer = after.map(page => ({...page, data: 'new ink'})); + const undone = applyOverlayPatch(newer, patch.before); + assert.equal(undone[0].textBoxes[0].y, -30); + assert.equal(undone[0].data, 'new ink'); + assert.deepEqual(applyOverlayPatch(undone, patch.after), newer); +}); +test('save waits for two queued native edits in order', async () => { + let pages = fixture(), resolve; + const first = new Promise(done => {resolve = done;}); + let calls = 0; + const queue = createOverlayTransactions({read: () => pages, write: next => {pages = next;}, + record: async () => ++calls === 1 ? first : true}); + const a = queue.commit(move(10)); + const saved = queue.flush(); + await Promise.resolve(); + const b = queue.commit(move(20)); + assert.equal(pages[0].textBoxes[0].y, -30); + resolve(true); + await saved; + assert.equal(pages[0].textBoxes[0].y, 20); + assert.equal(queue.hasPending(), false); + await Promise.all([a, b]); +}); +test('rejected native edits leave the saved overlay unchanged', async () => { + let pages = fixture(); + const before = pages; + const queue = createOverlayTransactions({read: () => pages, write: next => {pages = next;}, record: async () => false}); + await assert.rejects(queue.commit(move(20)), /could not record/); + assert.equal(pages, before); +}); +test('invalid patches do not partially mutate source objects', () => { + const pages = fixture(); + const patch = {format: 'opennotes-overlay-1', changes: [ + {pageId: 'page', collection: 'textBoxes', id: 'text', index: 0, set: {y: 20}, unset: []}, + {pageId: 'page', collection: 'textBoxes', id: 'missing', index: 0, set: {y: 20}, unset: []}, + ]}; + assert.throws(() => applyOverlayPatch(pages, JSON.stringify(patch)), /missing/); + assert.equal(pages[0].textBoxes[0].y, -30); +}); diff --git a/scripts/overlayTextInsertion.test.mjs b/scripts/overlayTextInsertion.test.mjs index c4f0e00..846e18c 100644 --- a/scripts/overlayTextInsertion.test.mjs +++ b/scripts/overlayTextInsertion.test.mjs @@ -3,15 +3,18 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; +import * as continuousAssets from '../src/utils/continuousAssets.ts'; // Exercise the real Pressable handler; native locationX/Y are already local // to the transformed overlay, regardless of the viewport zoom and scroll. -function harness(transform, pageCount = 3) { +function harness(transform, pageCount = 3, customPages) { const created = []; const selected = []; + const updates = []; const react = { createElement: (type, props, ...children) => ({ type, props, children }), useCallback: (fn) => fn, + useMemo: (fn) => fn(), useEffect: () => {}, useRef: (current) => ({ current }), useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}], @@ -34,6 +37,7 @@ function harness(transform, pageCount = 3) { } const viewport = load('../src/hooks/useViewportTransform.ts'); const { OverlayLayer } = load('../src/components/editor/OverlayLayer.tsx', { + 'react-native-reanimated': {useSharedValue: value => ({value})}, 'react-native': { View: 'View', Pressable: 'Pressable', Keyboard: { dismiss() {} }, StyleSheet: { absoluteFill: {}, create: (styles) => styles }, @@ -41,18 +45,21 @@ function harness(transform, pageCount = 3) { './TextBoxOverlay': { TextBoxOverlay: 'TextBoxOverlay' }, './ImageInsertOverlay': { ImageInsertOverlay: 'ImageInsertOverlay' }, '../../hooks/useViewportTransform': viewport, + '../../utils/continuousAssets': continuousAssets, }); const tree = OverlayLayer({ store: { getSnapshot: () => transform }, - pages: Array.from({ length: pageCount }, (_, i) => ({ id: String(i) })), + pages: customPages ?? Array.from({ length: pageCount }, (_, i) => ({ id: String(i) })), pageWidth: 820, pageHeight: 1061, activeTool: 'text', activeColor: '#123456', selection: null, onCreateTextBoxId: () => 'new-text', onCreateTextBox: (pageIndex, box) => created.push({ pageIndex, x: box.x, y: box.y }), onSelectionChange: (value) => selected.push(value), + onUpdateInsertedElement: (owner, id, patch) => updates.push({ owner, id, patch }), + onUpdateTextBox: (owner, id, patch) => updates.push({ owner, id, patch }), }); return { - created, selected, + created, selected, tree, updates, tap: (x, y) => tree.children[0].props.onPress({ nativeEvent: { locationX: x, locationY: y } }), }; } @@ -93,3 +100,40 @@ test('taps outside the page content or with invalid coordinates do not create te empty.tap(10, 10); assert.equal(empty.created.length, 0); }); + + +test('cross-page overlays edit the original asset through notebook coordinates', () => { + const pages = [{ id: '0' }, { id: '1', insertedElements: [ + { id: 'image', type: 'image', x: 10, y: 1000, width: 200, height: 200 }, + ], textBoxes: [{ id: 'text', x: 10, y: -50, width: 200, height: 100 }] }, { id: '2' }]; + const h = harness({ scale: 1, translateX: 0, translateY: 0 }, 3, pages); + const host = h.tree.children[1][1]; + const image = host.children[0][0]; + const text = host.children[1][0]; + assert.equal(image.props.element.y, 2061); + assert.equal(text.props.textBox.y, 1011); + assert.equal(image.props.pageHeight, 3183); + image.props.onUpdate({ y: 900 }); + text.props.onCommit({ y: 2000, isEditing: false }); + assert.equal(h.updates[0].owner, 1); + assert.equal(h.updates[0].id, 'image'); + assert.equal(h.updates[0].patch.y, -161); + assert.equal(h.updates[1].patch.y, 939); + assert.equal(pages[1].insertedElements[0].y, 1000); +}); + +test('image layering compares every owner page and does not add page clipping views', () => { + const pages = [ + { id: '0', insertedElements: [{ id: 'a', type: 'image', zIndex: -4 }] }, + { id: '1', insertedElements: [{ id: 'b', type: 'image', zIndex: 20 }] }, + ]; + const h = harness({ scale: 1 }, 2, pages); + assert.equal(h.tree.children[0].props.style[1].zIndex, -5); + const first = h.tree.children[1][0]; + assert.equal(first.props.style, undefined); + first.children[0][0].props.onBringToFront(); + assert.equal(h.updates[0].patch.zIndex, 21); + const second = h.tree.children[1][1]; + second.children[0][0].props.onSendToBack(); + assert.equal(h.updates[1].patch.zIndex, -5); +}); diff --git a/scripts/seed-native-audit.py b/scripts/seed-native-audit.py new file mode 100644 index 0000000..ef50e53 --- /dev/null +++ b/scripts/seed-native-audit.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Seed owned audit notes into an installed OpenNotes simulator app. + +Preserves all unrelated catalog entries and bodies. The app must be stopped; +only the 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 aa14a69..e66cc3d 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -50,6 +50,9 @@ export function EditorHeader({ > [styles.sideButton, pressed && { opacity: 0.5 }]} diff --git a/src/components/editor/ImageInsertOverlay.tsx b/src/components/editor/ImageInsertOverlay.tsx index d327a7f..e6c64d1 100644 --- a/src/components/editor/ImageInsertOverlay.tsx +++ b/src/components/editor/ImageInsertOverlay.tsx @@ -1,3 +1,5 @@ +import {useNativeCallback} from '../../hooks/native-callback'; +import {resizeFromCorner, localPointerDelta, anchorRotatedFrame, cropFromEdge, type ResizeHandle} from './overlayGeometry'; import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, @@ -23,12 +25,9 @@ import { useTheme } from '../../hooks/useTheme'; const DEFAULT_WIDTH = 240; const DEFAULT_HEIGHT = 240; -const DEFAULT_MIN_SIZE = 50; const HANDLE_SIZE = 24; const HANDLE_OFFSET = -HANDLE_SIZE / 2; -const MIN_CROP_SIZE = 36; -type ResizeHandle = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; type CropHandle = | 'left' | 'right' @@ -44,6 +43,7 @@ type CropRect = NonNullable; export interface ImageInsertOverlayProps { element: InsertedElement; pageScale: number; + gestureScale?: SharedValue; screenLeft: number; screenTop: number; pageWidth: number; @@ -69,68 +69,11 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -function resizeFromCorner( - direction: ResizeHandle, - baseX: number, - baseY: number, - baseWidth: number, - baseHeight: number, - dx: number, - dy: number, - pageWidth: number, - pageHeight: number, - preserveAspectRatio: boolean, -) { - 'worklet'; - const aspectRatio = baseHeight > 0 ? baseWidth / baseHeight : 1; - let nextX = baseX; - let nextY = baseY; - let nextWidth = baseWidth; - let nextHeight = baseHeight; - - if (preserveAspectRatio) { - const rawWidth = direction.includes('left') ? baseWidth - dx : baseWidth + dx; - const rawHeight = direction.includes('top') ? baseHeight - dy : baseHeight + dy; - const widthScale = rawWidth / baseWidth; - const heightScale = rawHeight / baseHeight; - const minScale = Math.max(DEFAULT_MIN_SIZE / baseWidth, DEFAULT_MIN_SIZE / baseHeight); - const uniformScale = Math.max(minScale, Math.max(widthScale, heightScale)); - - nextWidth = Math.min(pageWidth, baseWidth * uniformScale); - nextHeight = Math.min(pageHeight, nextWidth / aspectRatio); - - if (direction.includes('left')) nextX = baseX + (baseWidth - nextWidth); - if (direction.includes('top')) nextY = baseY + (baseHeight - nextHeight); - } else { - if (direction.includes('right')) { - nextWidth = Math.max(DEFAULT_MIN_SIZE, baseWidth + dx); - } - if (direction.includes('left')) { - const widthDelta = Math.min(dx, baseWidth - DEFAULT_MIN_SIZE); - nextWidth = baseWidth - widthDelta; - nextX = baseX + widthDelta; - } - if (direction.includes('bottom')) { - nextHeight = Math.max(DEFAULT_MIN_SIZE, baseHeight + dy); - } - if (direction.includes('top')) { - const heightDelta = Math.min(dy, baseHeight - DEFAULT_MIN_SIZE); - nextHeight = baseHeight - heightDelta; - nextY = baseY + heightDelta; - } - } - - nextX = clamp(nextX, 0, Math.max(0, pageWidth - nextWidth)); - nextY = clamp(nextY, 0, Math.max(0, pageHeight - nextHeight)); - nextWidth = Math.min(nextWidth, pageWidth - nextX); - nextHeight = Math.min(nextHeight, pageHeight - nextY); - - return { x: nextX, y: nextY, width: nextWidth, height: nextHeight }; -} export function ImageInsertOverlay({ element, pageScale, + gestureScale, screenLeft, screenTop, pageWidth, @@ -144,6 +87,7 @@ export function ImageInsertOverlay({ onSendToBack, onRemove, }: ImageInsertOverlayProps) { + const nativeSelect = useNativeCallback(onSelect); const theme = useTheme(); const [isCropMode, setIsCropMode] = useState(false); @@ -151,7 +95,7 @@ export function ImageInsertOverlay({ const initialHeight = element.height ?? DEFAULT_HEIGHT; const locked = Boolean(element.locked); const sourceUri = element.sourceUri ?? element.renderedImageUri; - const canCrop = element.type === 'image' && Boolean(sourceUri); + const canCrop = element.type === 'image' && Boolean(sourceUri) && !locked; const preserveAspectRatio = element.type === 'latex-figure'; const x = useSharedValue(element.x); @@ -202,6 +146,7 @@ export function ImageInsertOverlay({ cropX, cropY, element.cropRect, + element.rotation, element.height, element.rotation, element.width, @@ -227,14 +172,13 @@ export function ImageInsertOverlay({ if (!isSelected) setIsCropMode(false); }, [isSelected]); - const commitPosition = useCallback( + const commitPosition = useNativeCallback( (nextX: number, nextY: number) => { onUpdate({ x: nextX, y: nextY }); }, - [onUpdate], ); - const commitSize = useCallback( + const commitSize = useNativeCallback( (nextWidth: number, nextHeight: number, nextX: number, nextY: number) => { onUpdate({ x: nextX, @@ -243,19 +187,17 @@ export function ImageInsertOverlay({ height: nextHeight, }); }, - [onUpdate], ); - const commitRotation = useCallback( + const commitRotation = useNativeCallback( (nextRotation: number) => { onUpdate({ rotation: nextRotation }); }, - [onUpdate], ); - const selectFromFinger = useCallback(() => { + const selectFromFinger = useNativeCallback(() => { onSelect(); - }, [onSelect]); + }); const handleFingerPointerDown = useCallback((event: any) => { const pointerType = event.nativeEvent.pointerType; @@ -272,8 +214,11 @@ export function ImageInsertOverlay({ const fullWidth = (element.width ?? DEFAULT_WIDTH) / Math.max(0.001, element.cropRect.width); const fullHeight = (element.height ?? DEFAULT_HEIGHT) / Math.max(0.001, element.cropRect.height); - const fullX = element.x - element.cropRect.x * fullWidth; - const fullY = element.y - element.cropRect.y * fullHeight; + const fullFrame = anchorRotatedFrame({x: element.x, y: element.y, + width: element.width ?? DEFAULT_WIDTH, height: element.height ?? DEFAULT_HEIGHT}, + {x: element.x - element.cropRect.x * fullWidth, y: element.y - element.cropRect.y * fullHeight, + width: fullWidth, height: fullHeight}, element.rotation ?? 0); + const fullX = fullFrame.x, fullY = fullFrame.y; x.value = fullX; y.value = fullY; @@ -295,6 +240,7 @@ export function ImageInsertOverlay({ cropX, cropY, element.cropRect, + element.rotation, element.height, element.width, element.x, @@ -310,16 +256,21 @@ export function ImageInsertOverlay({ y, ]); - const dragGesture = Gesture.Pan() + const dragOriginX = useSharedValue(0); + const dragOriginY = useSharedValue(0); + const dragGesture = Gesture.Pan().maxPointers(1) .enabled(isSelected && !isCropMode) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {dragOriginX.value = touch.absoluteX; dragOriginY.value = touch.absoluteY;} + hasMoved.value = false; runOnJS(selectFromFinger)(); }) .onTouchesMove((_event, stateManager) => { @@ -339,21 +290,21 @@ export function ImageInsertOverlay({ 'worklet'; if (locked || !hasMoved.value) return; x.value = clamp( - savedX.value + evt.translationX / pageScale, + savedX.value + (evt.absoluteX - dragOriginX.value) / (gestureScale?.value ?? pageScale), 0, Math.max(0, pageWidth - width.value), ); - y.value = clamp( - savedY.value + evt.translationY / pageScale, - 0, - Math.max(0, pageHeight - height.value), - ); + // The owner page remains stable when an asset crosses a page boundary. + y.value = savedY.value + (evt.absoluteY - dragOriginY.value) / (gestureScale?.value ?? pageScale); }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; - if (hasMoved.value) { - runOnJS(commitPosition)(x.value, y.value); - } + if (success && hasMoved.value) {runOnJS(commitPosition)(x.value, y.value);} + }) + .onFinalize((_event, success) => { + 'worklet'; + if (!success && hasMoved.value) {x.value = savedX.value; y.value = savedY.value;} + hasMoved.value = false; }); const tapGesture = Gesture.Tap() @@ -362,21 +313,21 @@ export function ImageInsertOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); } }) .onEnd((_evt, success) => { 'worklet'; - if (success) runOnJS(onSelect)(); + if (success) runOnJS(nativeSelect)(); }); - const rotateGesture = Gesture.Pan() + const rotateGesture = Gesture.Pan().maxPointers(1) .enabled(isSelected && !locked && !isCropMode) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } @@ -391,7 +342,7 @@ export function ImageInsertOverlay({ evt.absoluteY - rotateCenterY.value, evt.absoluteX - rotateCenterX.value, ); - runOnJS(onSelect)(); + runOnJS(nativeSelect)(); }) .onUpdate((evt) => { 'worklet'; @@ -403,21 +354,26 @@ export function ImageInsertOverlay({ savedRotation.value + ((currentAngle - rotateStartPointerAngle.value) * 180) / Math.PI; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; - runOnJS(commitRotation)(rotation.value); + if (success) runOnJS(commitRotation)(rotation.value); + else rotation.value = savedRotation.value; }); + const resizeOriginX = useSharedValue(0); + const resizeOriginY = useSharedValue(0); const createResizeGesture = (direction: ResizeHandle) => - Gesture.Pan() + Gesture.Pan().maxPointers(1) .enabled(isSelected && !locked && !isCropMode) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {resizeOriginX.value = touch.absoluteX; resizeOriginY.value = touch.absoluteY;} stateManager.activate(); }) .onStart(() => { @@ -429,26 +385,30 @@ export function ImageInsertOverlay({ }) .onUpdate((evt) => { 'worklet'; - const next = resizeFromCorner( + const delta = localPointerDelta(evt.absoluteX - resizeOriginX.value, + evt.absoluteY - resizeOriginY.value, gestureScale?.value ?? pageScale, rotation.value); + const resized = resizeFromCorner( direction, savedX.value, savedY.value, savedWidth.value, savedHeight.value, - evt.translationX / pageScale, - evt.translationY / pageScale, + delta.x, + delta.y, pageWidth, - pageHeight, preserveAspectRatio, ); + const next = anchorRotatedFrame({x: savedX.value, y: savedY.value, + width: savedWidth.value, height: savedHeight.value}, resized, rotation.value); x.value = next.x; y.value = next.y; width.value = next.width; height.value = next.height; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; - runOnJS(commitSize)(width.value, height.value, x.value, y.value); + if (success) runOnJS(commitSize)(width.value, height.value, x.value, y.value); + else { x.value = savedX.value; y.value = savedY.value; width.value = savedWidth.value; height.value = savedHeight.value; } }); const animatedStyle = useAnimatedStyle(() => ({ @@ -470,6 +430,8 @@ export function ImageInsertOverlay({ return ( {!locked && !isCropMode ? ( - + ) : null} {canCrop ? ( setIsCropMode((value) => !value)} @@ -524,6 +487,7 @@ export function ImageInsertOverlay({ {isCropMode ? ( <> ) : null} onUpdate({ locked: !locked })} + onPress={() => { setIsCropMode(false); onUpdate({ locked: !locked }); }} /> ReturnType; }) { return ( <> {RESIZE_HANDLES.map((handle) => ( - + ))} @@ -711,7 +681,10 @@ function ResizeHandles({ } function CropHandles({ + elementId, + rotation, pageScale, + gestureScale, width, height, x, @@ -730,7 +703,10 @@ function CropHandles({ savedCropHeight, onUpdateCrop, }: { + elementId: string; + rotation: SharedValue; pageScale: number; + gestureScale?: SharedValue; width: SharedValue; height: SharedValue; x: SharedValue; @@ -755,12 +731,16 @@ function CropHandles({ y: number, ) => void; }) { + const nativeCrop = useNativeCallback(onUpdateCrop); + const touchStart = useSharedValue({x: 0, y: 0}); const createCropGesture = (edge: CropHandle) => - Gesture.Pan() + Gesture.Pan().maxPointers(1) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + const touch = event.allTouches[0]; + if (touch) touchStart.value = {x: touch.absoluteX, y: touch.absoluteY}; + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } @@ -782,72 +762,25 @@ function CropHandles({ }) .onUpdate((event) => { 'worklet'; - const fullWidth = savedWidth.value / Math.max(0.001, savedCropWidth.value); - const fullHeight = savedHeight.value / Math.max(0.001, savedCropHeight.value); - const minWidth = Math.min(savedWidth.value, MIN_CROP_SIZE); - const minHeight = Math.min(savedHeight.value, MIN_CROP_SIZE); - let nextX = savedX.value; - let nextY = savedY.value; - let nextWidth = savedWidth.value; - let nextHeight = savedHeight.value; - let nextCropX = savedCropX.value; - let nextCropY = savedCropY.value; - let nextCropWidth = savedCropWidth.value; - let nextCropHeight = savedCropHeight.value; - - if (edge.includes('left')) { - const maxDelta = Math.min( - savedWidth.value - minWidth, - (1 - savedCropX.value) * fullWidth - minWidth, - ); - const dx = Math.max( - -savedCropX.value * fullWidth, - Math.min(maxDelta, event.translationX / pageScale), - ); - nextX = savedX.value + dx; - nextWidth = savedWidth.value - dx; - nextCropX = savedCropX.value + dx / fullWidth; - nextCropWidth = savedCropWidth.value - dx / fullWidth; - } else if (edge.includes('right')) { - const minDelta = minWidth - savedWidth.value; - const maxDelta = (1 - savedCropX.value - savedCropWidth.value) * fullWidth; - const dx = Math.max(minDelta, Math.min(maxDelta, event.translationX / pageScale)); - nextWidth = savedWidth.value + dx; - nextCropWidth = savedCropWidth.value + dx / fullWidth; - } - - if (edge.includes('top')) { - const maxDelta = Math.min( - savedHeight.value - minHeight, - (1 - savedCropY.value) * fullHeight - minHeight, - ); - const dy = Math.max( - -savedCropY.value * fullHeight, - Math.min(maxDelta, event.translationY / pageScale), - ); - nextY = savedY.value + dy; - nextHeight = savedHeight.value - dy; - nextCropY = savedCropY.value + dy / fullHeight; - nextCropHeight = savedCropHeight.value - dy / fullHeight; - } else if (edge.includes('bottom')) { - const minDelta = minHeight - savedHeight.value; - const maxDelta = (1 - savedCropY.value - savedCropHeight.value) * fullHeight; - const dy = Math.max(minDelta, Math.min(maxDelta, event.translationY / pageScale)); - nextHeight = savedHeight.value + dy; - nextCropHeight = savedCropHeight.value + dy / fullHeight; - } - - x.value = nextX; - y.value = nextY; - width.value = nextWidth; - height.value = nextHeight; - cropX.value = Math.max(0, Math.min(1, nextCropX)); - cropY.value = Math.max(0, Math.min(1, nextCropY)); - cropWidth.value = Math.max(0.02, Math.min(1 - cropX.value, nextCropWidth)); - cropHeight.value = Math.max(0.02, Math.min(1 - cropY.value, nextCropHeight)); + const delta = localPointerDelta(event.absoluteX - touchStart.value.x, + event.absoluteY - touchStart.value.y, gestureScale?.value ?? pageScale, rotation.value); + const next = cropFromEdge({x: savedX.value, y: savedY.value, + width: savedWidth.value, height: savedHeight.value}, + {x: savedCropX.value, y: savedCropY.value, width: savedCropWidth.value, height: savedCropHeight.value}, + edge, delta.x, delta.y, rotation.value); + x.value = next.frame.x; y.value = next.frame.y; + width.value = next.frame.width; height.value = next.frame.height; + cropX.value = next.crop.x; cropY.value = next.crop.y; + cropWidth.value = next.crop.width; cropHeight.value = next.crop.height; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; + if (!success) { + x.value = savedX.value; y.value = savedY.value; width.value = savedWidth.value; height.value = savedHeight.value; + cropX.value = savedCropX.value; cropY.value = savedCropY.value; + cropWidth.value = savedCropWidth.value; cropHeight.value = savedCropHeight.value; + return; + } const nextCropRect = { x: cropX.value, y: cropY.value, @@ -859,7 +792,7 @@ function CropHandles({ nextCropRect.y <= 0.001 && nextCropRect.width >= 0.999 && nextCropRect.height >= 0.999; - runOnJS(onUpdateCrop)( + runOnJS(nativeCrop)( isFullImage ? undefined : nextCropRect, width.value, height.value, @@ -873,7 +806,7 @@ function CropHandles({ {CROP_HANDLES.map((handle) => ( - ['name']; color: string; onPress: () => void; }) { return ( { + let min = 0; + let max = 0; + for (const page of pages) { + for (const element of page.insertedElements ?? []) { + min = Math.min(min, element.zIndex ?? 0); + max = Math.max(max, element.zIndex ?? 0); + } + } + return { min, max }; + }, [pages]); const transformRef = useRef(store.getSnapshot()); const [hasTransform, setHasTransform] = useState(() => transformRef.current !== null); + const gestureScale = useSharedValue(store.getSnapshot()?.scale ?? 1); useEffect(() => { const unsub = store.subscribe(() => { const next = store.getSnapshot(); if (!next) return; transformRef.current = next; + gestureScale.value = next.scale; setHasTransform(true); }); return unsub; @@ -151,7 +167,7 @@ export function OverlayLayer({ {captureTaps ? ( ) : null} @@ -161,19 +177,7 @@ export function OverlayLayer({ (a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0), ); return ( - + {elements.map((el) => { const canRenderElement = el.type === 'image' || @@ -186,35 +190,36 @@ export function OverlayLayer({ return canRenderElement ? ( onSelectionChange({ type: 'image', pageIndex, id: el.id }) } - onUpdate={(patch) => onUpdateInsertedElement(pageIndex, el.id, patch)} + onUpdate={(patch) => onUpdateInsertedElement(pageIndex, el.id, patchForOwner(patch, pageIndex, pageHeight))} onUpdateCrop={(cropRect, width, height, x, y) => onUpdateInsertedElement(pageIndex, el.id, { cropRect, width, height, x, - y, + y: y === undefined ? undefined : y - pageIndex * pageHeight, }) } onDuplicate={() => onDuplicateInsertedElement(pageIndex, el.id)} onBringToFront={() => { const nextZIndex = - Math.max(0, ...elements.map((candidate) => candidate.zIndex ?? 0)) + 1; + imageLayerBounds.max + 1; onUpdateInsertedElement(pageIndex, el.id, { zIndex: nextZIndex }); }} onSendToBack={() => { const nextZIndex = - Math.min(0, ...elements.map((candidate) => candidate.zIndex ?? 0)) - 1; + imageLayerBounds.min - 1; onUpdateInsertedElement(pageIndex, el.id, { zIndex: nextZIndex }); }} onRemove={() => { @@ -232,12 +237,13 @@ export function OverlayLayer({ return ( @@ -252,7 +258,7 @@ export function OverlayLayer({ }) } onCommit={(patch) => { - onUpdateTextBox(pageIndex, tb.id, patch); + onUpdateTextBox(pageIndex, tb.id, patchForOwner(patch, pageIndex, pageHeight)); if (patch.isEditing === false) { onSelectionChange({ type: 'text', pageIndex, id: tb.id }); } @@ -264,16 +270,9 @@ export function OverlayLayer({ /> ); })} - + ); })} ); } - -const styles = StyleSheet.create({ - pageHost: { - position: 'absolute', - overflow: 'hidden', - }, -}); diff --git a/src/components/editor/README.md b/src/components/editor/README.md new file mode 100644 index 0000000..6d83eb8 --- /dev/null +++ b/src/components/editor/README.md @@ -0,0 +1,33 @@ +# 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 887a696..faa9bc2 100644 --- a/src/components/editor/TextBoxOverlay.tsx +++ b/src/components/editor/TextBoxOverlay.tsx @@ -1,3 +1,4 @@ +import {useNativeCallback} from '../../hooks/native-callback'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Platform, @@ -18,6 +19,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue, + type SharedValue, } from 'react-native-reanimated'; import type { InkTextBox } from '@mathnotes/mobile-ink'; import { useTheme } from '../../hooks/useTheme'; @@ -43,6 +45,7 @@ type ResizeHandle = export interface TextBoxOverlayProps { textBox: InkTextBox; pageScale: number; + gestureScale?: SharedValue; screenLeft: number; screenTop: number; pageWidth: number; @@ -95,9 +98,7 @@ function resizeFromHandle( } nextX = clamp(nextX, 0, Math.max(0, pageWidth - nextWidth)); - nextY = clamp(nextY, 0, Math.max(0, pageHeight - nextHeight)); nextWidth = Math.min(nextWidth, pageWidth - nextX); - nextHeight = Math.min(nextHeight, pageHeight - nextY); return { x: nextX, y: nextY, width: nextWidth, height: nextHeight }; } @@ -105,6 +106,7 @@ function resizeFromHandle( export function TextBoxOverlay({ textBox, pageScale, + gestureScale, screenLeft, screenTop, pageWidth, @@ -176,7 +178,7 @@ export function TextBoxOverlay({ y, ]); - const commitLayout = useCallback( + const commitLayout = useNativeCallback( (nextX: number, nextY: number, nextWidth: number, nextHeight: number) => { onCommit({ x: nextX, @@ -185,15 +187,14 @@ export function TextBoxOverlay({ height: nextHeight, }); }, - [onCommit], ); - const selectFromFinger = useCallback(() => { + const selectFromFinger = useNativeCallback(() => { if (!isSelectedRef.current) { suppressEditUntilRef.current = Date.now() + 250; } onSelect(); - }, [onSelect]); + }); const handleFingerPointerDown = useCallback((event: any) => { const pointerType = event.nativeEvent.pointerType; @@ -202,7 +203,7 @@ export function TextBoxOverlay({ selectFromFinger(); }, [isEditing, selectFromFinger]); - const handleTap = useCallback(() => { + const handleTap = useNativeCallback(() => { if (isSelectedRef.current) { if (Date.now() < suppressEditUntilRef.current) return; onStartEdit(); @@ -210,7 +211,7 @@ export function TextBoxOverlay({ suppressEditUntilRef.current = Date.now() + 250; onSelect(); } - }, [onSelect, onStartEdit]); + }); const handleContentChange = useCallback( (content: string) => { @@ -220,16 +221,21 @@ export function TextBoxOverlay({ [onCommit], ); - const dragGesture = Gesture.Pan() + const dragOriginX = useSharedValue(0); + const dragOriginY = useSharedValue(0); + const dragGesture = Gesture.Pan().maxPointers(1) .enabled(isSelected && !isEditing) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {dragOriginX.value = touch.absoluteX; dragOriginY.value = touch.absoluteY;} + isDragging.value = false; runOnJS(selectFromFinger)(); }) .onTouchesMove((_event, stateManager) => { @@ -246,20 +252,21 @@ export function TextBoxOverlay({ .onUpdate((evt) => { 'worklet'; x.value = clamp( - savedX.value + evt.translationX / pageScale, + savedX.value + (evt.absoluteX - dragOriginX.value) / (gestureScale?.value ?? pageScale), 0, Math.max(0, pageWidth - width.value), ); - y.value = clamp( - savedY.value + evt.translationY / pageScale, - 0, - Math.max(0, pageHeight - height.value), - ); + // The owner page remains stable when an asset crosses a page boundary. + y.value = savedY.value + (evt.absoluteY - dragOriginY.value) / (gestureScale?.value ?? pageScale); + }) + .onEnd((_event, success) => { + 'worklet'; + if (success && isDragging.value) {runOnJS(commitLayout)(x.value, y.value, width.value, height.value);} }) - .onEnd(() => { + .onFinalize((_event, success) => { 'worklet'; + if (!success && isDragging.value) {x.value = savedX.value; y.value = savedY.value;} isDragging.value = false; - runOnJS(commitLayout)(x.value, y.value, width.value, height.value); }); const tapGesture = Gesture.Tap() @@ -268,7 +275,7 @@ export function TextBoxOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); } }) @@ -277,16 +284,20 @@ export function TextBoxOverlay({ if (success) runOnJS(handleTap)(); }); + const resizeOriginX = useSharedValue(0); + const resizeOriginY = useSharedValue(0); const createResizeGesture = (handle: ResizeHandle) => - Gesture.Pan() + Gesture.Pan().maxPointers(1) .enabled(isSelected && !isEditing) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === PointerType.STYLUS) { + if (event.pointerType === PointerType.STYLUS || event.numberOfTouches > 1) { stateManager.fail(); return; } + const touch = event.allTouches[0]; + if (touch) {resizeOriginX.value = touch.absoluteX; resizeOriginY.value = touch.absoluteY;} stateManager.activate(); }) .onStart(() => { @@ -305,8 +316,8 @@ export function TextBoxOverlay({ savedY.value, savedWidth.value, savedHeight.value, - evt.translationX / pageScale, - evt.translationY / pageScale, + (evt.absoluteX - resizeOriginX.value) / (gestureScale?.value ?? pageScale), + (evt.absoluteY - resizeOriginY.value) / (gestureScale?.value ?? pageScale), pageWidth, pageHeight, ); @@ -315,10 +326,11 @@ export function TextBoxOverlay({ width.value = next.width; height.value = next.height; }) - .onEnd(() => { + .onEnd((_event, success) => { 'worklet'; isResizing.value = false; - runOnJS(commitLayout)(x.value, y.value, width.value, height.value); + if (success) runOnJS(commitLayout)(x.value, y.value, width.value, height.value); + else { x.value = savedX.value; y.value = savedY.value; width.value = savedWidth.value; height.value = savedHeight.value; } }); const animatedStyle = useAnimatedStyle(() => ({ @@ -332,7 +344,6 @@ export function TextBoxOverlay({ transform: [ { translateX: x.value }, { translateY: y.value }, - { scale: isDragging.value || isResizing.value ? 1.02 : 1 }, ], })); @@ -343,6 +354,8 @@ export function TextBoxOverlay({ return ( - + ; unset?: string[] }; +type Patch = { format: 'opennotes-overlay-1'; changes: Change[] }; +const collections: Collection[] = ['insertedElements', 'textBoxes']; +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const items = (page: NotebookPage, key: Collection) => (page[key] ?? []) as unknown as Item[]; + +/** History records only changed overlay fields; ink and derived metadata stay current. */ +export function overlayPatches(before: NotebookPage[], after: NotebookPage[]): {before: string; after: string} | null { + if (before.length !== after.length || before.some((page, i) => page.id !== after[i]?.id)) { + throw new Error('Overlay edits cannot change notebook pages'); + } + const backward: Change[] = [], forward: Change[] = []; + before.forEach((page, pageIndex) => { + for (const collection of collections) { + const oldItems = items(page, collection), newItems = items(after[pageIndex], collection); + if (oldItems === newItems) continue; + if (new Set(oldItems.map(item => item.id)).size !== oldItems.length || + new Set(newItems.map(item => item.id)).size !== newItems.length) { + throw new Error('Overlay IDs must be unique within a page'); + } + const oldMap = new Map(oldItems.map((item, index) => [item.id, {item, index}])); + const newMap = new Map(newItems.map((item, index) => [item.id, {item, index}])); + const retainedBefore = oldItems.filter(item => newMap.has(item.id)).map(item => item.id); + const retainedAfter = newItems.filter(item => oldMap.has(item.id)).map(item => item.id); + if (!same(retainedBefore, retainedAfter)) throw new Error('Overlay ordering must use zIndex'); + for (const id of new Set([...oldMap.keys(), ...newMap.keys()])) { + const old = oldMap.get(id), next = newMap.get(id); + if (old?.item === next?.item) continue; + const common = {pageId: page.id, collection, id, index: old?.index ?? next!.index}; + if (!old || !next) { + backward.push({...common, replace: old?.item ?? null}); + forward.push({...common, replace: next?.item ?? null}); + continue; + } + const toBefore: Change = {...common, set: {}, unset: []}; + const toAfter: Change = {...common, set: {}, unset: []}; + for (const key of new Set([...Object.keys(old.item), ...Object.keys(next.item)])) { + if (key === 'updatedAt' || key === 'id' || same(old.item[key], next.item[key])) continue; + if (forbidden.has(key)) throw new Error('Invalid overlay property'); + if (old.item[key] === undefined) toBefore.unset!.push(key); + else toBefore.set![key] = old.item[key]; + if (next.item[key] === undefined) toAfter.unset!.push(key); + else toAfter.set![key] = next.item[key]; + } + if (Object.keys(toAfter.set!).length || toAfter.unset!.length) { + backward.push(toBefore); forward.push(toAfter); + } + } + } + }); + return forward.length ? { + before: JSON.stringify({format: 'opennotes-overlay-1', changes: backward}), + after: JSON.stringify({format: 'opennotes-overlay-1', changes: forward}), + } : null; +} + +export function applyOverlayPatch(pages: NotebookPage[], payload: string): NotebookPage[] { + const patch: Patch = JSON.parse(payload); + if (patch?.format !== 'opennotes-overlay-1' || !Array.isArray(patch.changes)) { + throw new Error('Unrecognized overlay history'); + } + let next = pages; + for (const change of patch.changes) { + if (!change || !collections.includes(change.collection) || typeof change.id !== 'string' || + typeof change.pageId !== 'string' || !Number.isSafeInteger(change.index) || change.index < 0) { + throw new Error('Invalid overlay history entry'); + } + const pageIndex = next.findIndex(page => page.id === change.pageId); + if (pageIndex < 0) throw new Error('Overlay history refers to a removed page'); + const existing = items(next[pageIndex], change.collection); + const index = existing.findIndex(item => item.id === change.id); + const updated = [...existing]; + if ('replace' in change) { + if (change.replace === null) { + if (index >= 0) updated.splice(index, 1); + } else { + if (!isRecord(change.replace) || change.replace.id !== change.id || + Object.keys(change.replace).some(key => forbidden.has(key))) throw new Error('Invalid overlay replacement'); + const value = {...change.replace}; + // A restored in-flight figure must restart compilation, not wait on a cancelled job. + if (value.type === 'latex-figure' && value.renderStatus === 'rendering') value.renderStatus = 'pending'; + if (index >= 0) updated[index] = value; + else updated.splice(Math.min(change.index, updated.length), 0, value); + } + } else { + if (index < 0 || !isRecord(change.set) || !Array.isArray(change.unset)) throw new Error('Overlay history target is missing'); + const keys = [...Object.keys(change.set), ...change.unset]; + if (keys.some(key => typeof key !== 'string' || forbidden.has(key) || key === 'id')) throw new Error('Invalid overlay property'); + const value = {...updated[index], ...change.set}; + for (const key of change.unset) delete value[key]; + updated[index] = value; + } + next = next.map((page, i) => i === pageIndex ? {...page, [change.collection]: updated} as NotebookPage : page); + } + return next; +} diff --git a/src/components/editor/history/transactions.ts b/src/components/editor/history/transactions.ts new file mode 100644 index 0000000..6a8a08c --- /dev/null +++ b/src/components/editor/history/transactions.ts @@ -0,0 +1,62 @@ +import type {NotebookPage} from '@mathnotes/mobile-ink'; +import {applyOverlayPatch, overlayPatches} from './patch.ts'; + +type Update = NotebookPage[] | ((pages: NotebookPage[]) => NotebookPage[]); +type Options = { + read: () => NotebookPage[]; + write: (pages: NotebookPage[]) => void; + onCommitFailure?: (error: Error) => void; + prepare?: (before: NotebookPage[], after: NotebookPage[]) => Promise; + record: (before: string, after: string) => Promise; +}; + +/** Serializes overlay commits with undo/redo and persistence barriers. */ +export function createOverlayTransactions({read, write, record, prepare, onCommitFailure}: Options) { + let tail: Promise = Promise.resolve(); + let pending = 0; + let disposed = false; + + function enqueue(operation: () => Promise): Promise { + if (disposed) return Promise.reject(new Error('Notebook history has closed')); + pending += 1; + const result = tail.then(async () => { + if (disposed) throw new Error('Notebook history has closed'); + return operation(); + }); + // The caller receives failures; later valid edits must remain usable. + tail = result.then(() => undefined, () => undefined).finally(() => {pending -= 1;}); + return result; + } + + return { + commit(update: Update): Promise { + return enqueue(async () => { + const before = read(); + // Evaluate outside React's state updater: StrictMode must not record twice. + let after = typeof update === 'function' ? update(before) : update; + if (prepare) after = await prepare(before, after); + if (disposed) throw new Error('Notebook closed while preparing overlay edit'); + const patches = overlayPatches(before, after); + if (!patches) return; + if (!await record(patches.before, patches.after)) { + throw new Error('Ink engine could not record the overlay edit'); + } + if (disposed) throw new Error('Notebook closed before overlay commit'); + // Derived image/render metadata and newly captured ink may have arrived + // while crossing the native bridge. Apply just the user's changed fields. + try {write(applyOverlayPatch(read(), patches.after));} catch (error) { + onCommitFailure?.(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }); + }, + run: enqueue, + async flush(): Promise { + // Include operations enqueued while a bridge call was in flight. + let observed: Promise; + do {observed = tail; await observed;} while (observed !== tail); + }, + hasPending: () => pending > 0, + dispose() {disposed = true;}, + }; +} diff --git a/src/components/editor/history/useOverlayHistory.ts b/src/components/editor/history/useOverlayHistory.ts new file mode 100644 index 0000000..70ceecc --- /dev/null +++ b/src/components/editor/history/useOverlayHistory.ts @@ -0,0 +1,117 @@ +import {useCallback, useEffect, useRef} from 'react'; +import {Alert} from 'react-native'; +import {t} from '../../../i18n'; +import type {NotebookPage, ContinuousDocumentCanvasRef} from '@mathnotes/mobile-ink'; +type DrawingEvent = {nativeEvent: {historyAction?: 'undo' | 'redo'; externalEdit?: {payload: string}}}; +import {applyOverlayPatch} from './patch'; +import {createOverlayTransactions} from './transactions'; + +type Action = 'undo' | 'redo'; +type Inputs = { + fileId?: string | null; + readPages: () => NotebookPage[]; + canvasRef: React.RefObject; + writePages: (pages: NotebookPage[]) => void; + onChange: () => void; +}; +type Ack = {action: Action; resolve: () => void; reject: (error: Error) => void; timer: ReturnType}; + +export function useOverlayHistory(options: Inputs) { + const latest = useRef(options); + latest.current = options; + const queue = useRef | null>(null); + const ack = useRef(null); + const fault = useRef(null); + const write = useCallback((pages: NotebookPage[]) => { + latest.current.writePages(pages); + latest.current.onChange(); + }, []); + useEffect(() => { + fault.current = null; + const current = createOverlayTransactions({ + read: () => latest.current.readPages(), + write, + onCommitFailure: error => {fault.current = error;}, + record: async (before, after) => { + if (fault.current) throw fault.current; + const canvas = latest.current.canvasRef.current; + if (!canvas) throw new Error('Ink canvas is unavailable'); + return canvas.recordExternalEdit(before, after); + }, + }); + queue.current = current; + return () => { + current.dispose(); + if (ack.current) { + clearTimeout(ack.current.timer); + ack.current.reject(new Error('Notebook closed during history operation')); + ack.current = null; + } + if (queue.current === current) queue.current = null; + }; + }, [options.fileId, write]); + + const report = useCallback((error: unknown) => { + console.error('[OverlayHistory] Edit could not complete', error); + Alert.alert(t.common.pleaseTryAgain, t.common.pleaseTryAgainLater); + }, []); + const setOverlayPages = useCallback>>(update => { + if (!queue.current) {report(new Error('Notebook history is unavailable')); return;} + void queue.current.commit(update).catch(report); + }, [report]); + const flushOverlayHistory = useCallback(async () => { + await queue.current?.flush(); + if (fault.current) throw fault.current; + }, []); + const runAfterOverlayHistory = useCallback((operation: () => Promise): Promise => { + if (!queue.current) return Promise.reject(new Error('Notebook history is unavailable')); + return queue.current.run(async () => { + if (fault.current) throw fault.current; + return operation(); + }); + }, []); + const hasPendingOverlayHistory = useCallback(() => Boolean(queue.current?.hasPending()), []); + const handleHistoryDrawingChange = useCallback<(event?: DrawingEvent) => void>(event => { + const data = event?.nativeEvent; + try { + if (data?.externalEdit) write(applyOverlayPatch(latest.current.readPages(), data.externalEdit.payload)); + const pending = ack.current; + if (pending && data?.historyAction === pending.action) { + clearTimeout(pending.timer); + pending.resolve(); + ack.current = null; + } + } catch (error) { + fault.current = error instanceof Error ? error : new Error(String(error)); + if (ack.current) { + clearTimeout(ack.current.timer); + ack.current.reject(fault.current); + ack.current = null; + } else report(error); + } + }, [report, write]); + const runHistory = useCallback((action: Action) => { + if (!queue.current) {report(new Error('Notebook history is unavailable')); return;} + void queue.current.run(async () => { + if (fault.current) throw fault.current; + const canvas = latest.current.canvasRef.current; + if (!canvas) throw new Error('Ink canvas is unavailable'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + fault.current = new Error('Native history acknowledgement timed out'); + ack.current = null; + reject(fault.current); + }, 10000); + ack.current = {action, resolve, reject, timer}; + try {canvas[action]();} catch (error) { + clearTimeout(timer); + ack.current = null; + reject(error); + } + }); + }).catch(report); + }, [report]); + return {setOverlayPages, flushOverlayHistory, hasPendingOverlayHistory, runAfterOverlayHistory, + handleHistoryDrawingChange, undoHistory: useCallback(() => runHistory('undo'), [runHistory]), + redoHistory: useCallback(() => runHistory('redo'), [runHistory])}; +} diff --git a/src/components/editor/overlayGeometry.ts b/src/components/editor/overlayGeometry.ts new file mode 100644 index 0000000..85e0b59 --- /dev/null +++ b/src/components/editor/overlayGeometry.ts @@ -0,0 +1,114 @@ +export type ResizeHandle = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +const DEFAULT_MIN_SIZE = 50; +function clamp(value: number, min: number, max: number): number { + 'worklet'; return Math.max(min, Math.min(max, value)); +} + +// Vertical positions are owner-relative and may span neighboring pages. +export function resizeFromCorner( + direction: ResizeHandle, + baseX: number, + baseY: number, + baseWidth: number, + baseHeight: number, + dx: number, + dy: number, + pageWidth: number, + preserveAspectRatio: boolean, +) { + 'worklet'; + const aspectRatio = baseHeight > 0 ? baseWidth / baseHeight : 1; + let nextX = baseX; + let nextY = baseY; + let nextWidth = baseWidth; + let nextHeight = baseHeight; + + if (preserveAspectRatio) { + const rawWidth = direction.includes('left') ? baseWidth - dx : baseWidth + dx; + const rawHeight = direction.includes('top') ? baseHeight - dy : baseHeight + dy; + const widthScale = rawWidth / baseWidth; + const heightScale = rawHeight / baseHeight; + const minScale = Math.max(DEFAULT_MIN_SIZE / baseWidth, DEFAULT_MIN_SIZE / baseHeight); + const uniformScale = Math.max(minScale, Math.max(widthScale, heightScale)); + + nextWidth = Math.min(pageWidth, baseWidth * uniformScale); + nextHeight = nextWidth / aspectRatio; + + if (direction.includes('left')) nextX = baseX + (baseWidth - nextWidth); + if (direction.includes('top')) nextY = baseY + (baseHeight - nextHeight); + } else { + if (direction.includes('right')) { + nextWidth = Math.max(DEFAULT_MIN_SIZE, baseWidth + dx); + } + if (direction.includes('left')) { + const widthDelta = Math.min(dx, baseWidth - DEFAULT_MIN_SIZE); + nextWidth = baseWidth - widthDelta; + nextX = baseX + widthDelta; + } + if (direction.includes('bottom')) { + nextHeight = Math.max(DEFAULT_MIN_SIZE, baseHeight + dy); + } + if (direction.includes('top')) { + const heightDelta = Math.min(dy, baseHeight - DEFAULT_MIN_SIZE); + nextHeight = baseHeight - heightDelta; + nextY = baseY + heightDelta; + } + } + + nextX = clamp(nextX, 0, Math.max(0, pageWidth - nextWidth)); + nextWidth = Math.min(nextWidth, pageWidth - nextX); + + return { x: nextX, y: nextY, width: nextWidth, height: nextHeight }; +} + + +export type AssetFrame = {x: number; y: number; width: number; height: number}; +export type CropRect = AssetFrame; +export type CropHandle = ResizeHandle | 'left' | 'right' | 'top' | 'bottom'; + +export function localPointerDelta(dx: number, dy: number, scale: number, rotation: number) { + 'worklet'; + const angle = rotation * Math.PI / 180; + return {x: (dx * Math.cos(angle) + dy * Math.sin(angle)) / scale, + y: (-dx * Math.sin(angle) + dy * Math.cos(angle)) / scale}; +} + +/** Convert a local-axis resize to a center-rotated frame, fixing the opposite edge. */ +export function anchorRotatedFrame(before: AssetFrame, next: AssetFrame, rotation: number): AssetFrame { + 'worklet'; + const angle = rotation * Math.PI / 180; + const dx = next.x - before.x + (next.width - before.width) / 2; + const dy = next.y - before.y + (next.height - before.height) / 2; + return {...next, + x: before.x + before.width / 2 + dx * Math.cos(angle) - dy * Math.sin(angle) - next.width / 2, + y: before.y + before.height / 2 + dx * Math.sin(angle) + dy * Math.cos(angle) - next.height / 2}; +} + +/** Deltas are local image-axis units; normalized crop and visible size stay in sync. */ +export function cropFromEdge(before: AssetFrame, crop: CropRect, edge: CropHandle, + dx: number, dy: number, rotation: number) { + 'worklet'; + const fullWidth = before.width / Math.max(0.001, crop.width); + const fullHeight = before.height / Math.max(0.001, crop.height); + const minWidth = Math.min(before.width, Math.max(36, fullWidth * 0.02)); + const minHeight = Math.min(before.height, Math.max(36, fullHeight * 0.02)); + const next = {...before}; + const nextCrop = {...crop}; + if (edge.includes('left')) { + const delta = clamp(dx, -crop.x * fullWidth, before.width - minWidth); + next.x += delta; next.width -= delta; + nextCrop.x += delta / fullWidth; nextCrop.width -= delta / fullWidth; + } else if (edge.includes('right')) { + const delta = clamp(dx, minWidth - before.width, (1 - crop.x - crop.width) * fullWidth); + next.width += delta; nextCrop.width += delta / fullWidth; + } + if (edge.includes('top')) { + const delta = clamp(dy, -crop.y * fullHeight, before.height - minHeight); + next.y += delta; next.height -= delta; + nextCrop.y += delta / fullHeight; nextCrop.height -= delta / fullHeight; + } else if (edge.includes('bottom')) { + const delta = clamp(dy, minHeight - before.height, (1 - crop.y - crop.height) * fullHeight); + next.height += delta; nextCrop.height += delta / fullHeight; + } + return {frame: anchorRotatedFrame(before, next, rotation), crop: nextCrop}; +} diff --git a/src/components/library/NoteCard.tsx b/src/components/library/NoteCard.tsx index c9acb9c..dd44e4a 100644 --- a/src/components/library/NoteCard.tsx +++ b/src/components/library/NoteCard.tsx @@ -20,6 +20,7 @@ export function NoteCard({ note, onPress, onLongPress, onMenuPress }: NoteCardPr const tileWidth = useLibraryTileWidth(); return ( () { + 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/services/exportService.ts b/src/services/exportService.ts index 3a6a5a7..d34113e 100644 --- a/src/services/exportService.ts +++ b/src/services/exportService.ts @@ -2,7 +2,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import * as Print from 'expo-print'; import * as Sharing from 'expo-sharing'; import { PixelRatio } from 'react-native'; -import { batchExportPages, type SerializedNotebookData } from '@mathnotes/mobile-ink'; +import { batchExportPages, projectDocumentPages, type SerializedNotebookData } from '@mathnotes/mobile-ink'; import { buildPdfHtml, NOTE_PAGE_HEIGHT, @@ -12,11 +12,10 @@ import { type PrintablePage, } from './pdfExportHtml'; import { PDFUtilsModule } from './pdfUtilsModule'; +import { indexAssetsByPage } from '../utils/continuousAssets'; const EXPORT_SCALE = 1.0; const DEFAULT_NATIVE_BATCH_SIZE = 8; -const LARGE_NATIVE_BATCH_SIZE = 1; -const LARGE_NOTEBOOK_PREVIEW_THRESHOLD = 150; const NATIVE_BATCH_PAUSE_MS = 16; // Pages rendered, embedded as base64 and printed per pass. Each page raster // is several MB of base64 in the JS heap, so printing a whole notebook in @@ -43,22 +42,6 @@ function getPdfPageIndex( : pageIndex; } -async function toPrintableImageUri(uri: string | undefined): Promise { - if (!uri) return null; - if (uri.startsWith('data:image/')) return uri; - if (!uri.startsWith('file://')) return null; - - try { - const base64 = await FileSystem.readAsStringAsync(uri, { - encoding: FileSystem.EncodingType.Base64, - }); - return base64 ? `data:image/png;base64,${base64}` : null; - } catch (error) { - if (__DEV__) console.warn('[exportService] could not read preview image', uri, error); - return null; - } -} - function mimeTypeForImage(uri: string): string { const extension = uri.match(/\.([a-z0-9]+)(?:[?#]|$)/i)?.[1]?.toLowerCase(); switch (extension) { @@ -96,13 +79,14 @@ async function embedInsertedImage(uri: string): Promise { } async function preparePrintablePages( - notebookPages: SerializedNotebookData['pages'], + indexedAssets: ReturnType, rasterUris: string[], + pageIndexes: number[], ): Promise { const cachedImages = new Map(); const pages: PrintablePage[] = []; - for (let index = 0; index < notebookPages.length; index += 1) { - const page = notebookPages[index]; + for (let index = 0; index < pageIndexes.length; index += 1) { + const page = indexedAssets[pageIndexes[index]]; const images: PrintablePage['images'] = []; for (const element of page.insertedElements ?? []) { const sourceUri = element.sourceUri ?? element.renderedImageUri; @@ -147,8 +131,13 @@ async function renderNativePagesInChunks(options: { for (let start = 0; start < pageIndexes.length; start += batchSize) { const chunkPageIndexes = pageIndexes.slice(start, start + batchSize); const chunkPages = chunkPageIndexes.map((pageIndex) => data.pages[pageIndex]); + const inkPayloads = await projectDocumentPages( + data.pages.map(page => page.data ?? '{"pages":{}}'), + exportHeight, + chunkPageIndexes, + ); const pngUris = await batchExportPages( - chunkPages.map((p) => p?.data ?? '{"pages":{}}'), + inkPayloads, chunkPages.map((p) => p?.pageType ?? 'plain'), exportWidth, exportHeight, @@ -182,40 +171,17 @@ async function collectExportImages(options: { pdfBackgroundUri?: string | null; }): Promise { const { data, pageIndexes, exportWidth, exportHeight, pdfBackgroundUri } = options; - const images = new Map(); - const nativePageIndexes: number[] = []; - const preferStoredPreviews = data.pages.length >= LARGE_NOTEBOOK_PREVIEW_THRESHOLD; - - if (preferStoredPreviews) { - for (const pageIndex of pageIndexes) { - const previewUri = await toPrintableImageUri(data.pages[pageIndex]?.previewUri); - if (previewUri) { - images.set(pageIndex, previewUri); - } else { - nativePageIndexes.push(pageIndex); - } - } - } else { - nativePageIndexes.push(...pageIndexes); - } - - if (nativePageIndexes.length > 0) { - const rendered = await renderNativePagesInChunks({ - data, - pageIndexes: nativePageIndexes, - exportWidth, - exportHeight, - pdfBackgroundUri, - batchSize: preferStoredPreviews ? LARGE_NATIVE_BATCH_SIZE : DEFAULT_NATIVE_BATCH_SIZE, - }); - for (const [pageIndex, uri] of rendered) { - images.set(pageIndex, uri); - } - } - - return pageIndexes - .map((pageIndex) => images.get(pageIndex)) - .filter((uri): uri is string => typeof uri === 'string' && uri.length > 0); + // Saved thumbnails may omit ink owned by a neighboring page. Always render + // the canonical document, in bounded batches, for the exported PDF. + const rendered = await renderNativePagesInChunks({ + data, pageIndexes, exportWidth, exportHeight, pdfBackgroundUri, + batchSize: DEFAULT_NATIVE_BATCH_SIZE, + }); + return pageIndexes.map(index => { + const uri = rendered.get(index); + if (!uri) throw new Error(`Native export omitted page ${index + 1}`); + return uri; + }); } async function mergePrintedPasses(passUris: string[]): Promise { @@ -251,6 +217,7 @@ export async function exportNotebookAsPdf( const exportWidth = Math.round(NOTE_PAGE_WIDTH * nativeScale); const exportHeight = Math.round(NOTE_PAGE_HEIGHT * nativeScale); const pageCount = data.pages.length; + const indexedAssets = indexAssetsByPage(data.pages, NOTE_PAGE_HEIGHT); for (let start = 0; start < pageCount; start += PRINT_PASS_PAGES) { const pageIndexes = Array.from( @@ -272,8 +239,9 @@ export async function exportNotebookAsPdf( } const printablePages = await preparePrintablePages( - pageIndexes.map((pageIndex) => data.pages[pageIndex]), + indexedAssets, rasterUris, + pageIndexes, ); const printed = await Print.printToFileAsync({ html: buildPdfHtml(printablePages), diff --git a/src/utils/continuousAssets.ts b/src/utils/continuousAssets.ts new file mode 100644 index 0000000..b47d8ec --- /dev/null +++ b/src/utils/continuousAssets.ts @@ -0,0 +1,73 @@ +import type { InkTextBox, InsertedElement, NotebookPage } from '@mathnotes/mobile-ink'; + +/** Assets retain one owner page; editing uses notebook coordinates. */ +export function assetInNotebook(asset: T, owner: number, pageHeight: number): T { + return { ...asset, y: (asset.y ?? 0) + owner * pageHeight }; +} + +export function patchForOwner(patch: T, owner: number, pageHeight: number): T { + return patch.y === undefined ? patch : { ...patch, y: patch.y - owner * pageHeight }; +} + +type AssetBounds = { y?: number; width?: number; height?: number; rotation?: number }; +type PageAssets = { insertedElements: InsertedElement[]; textBoxes: InkTextBox[] }; + +function verticalBounds(asset: AssetBounds, defaultWidth: number, defaultHeight: number) { + const width = asset.width ?? defaultWidth; + const height = asset.height ?? defaultHeight; + const angle = (asset.rotation ?? 0) * Math.PI / 180; + const halfExtent = (Math.abs(Math.sin(angle)) * width + Math.abs(Math.cos(angle)) * height) / 2; + const center = (asset.y ?? 0) + height / 2; + return { top: center - halfExtent, bottom: center + halfExtent }; +} + +/** Index once per export: cost follows assets and overlaps, not pages × assets. */ +export function indexAssetsByPage(pages: NotebookPage[], pageHeight: number): PageAssets[] { + if (!Number.isFinite(pageHeight) || pageHeight <= 0) throw new Error('Invalid page height'); + const indexed: PageAssets[] = pages.map(() => ({ insertedElements: [], textBoxes: [] })); + function project( + asset: T, owner: number, defaultWidth: number, defaultHeight: number, + append: (target: number, local: T) => void, + ) { + const global = assetInNotebook(asset, owner, pageHeight); + const bounds = verticalBounds(global, defaultWidth, defaultHeight); + if (!Number.isFinite(bounds.top) || !Number.isFinite(bounds.bottom)) { + throw new Error('Cannot export an asset with invalid geometry'); + } + const first = Math.max(0, Math.floor(bounds.top / pageHeight)); + const last = Math.min(pages.length - 1, Math.ceil(bounds.bottom / pageHeight) - 1); + for (let target = first; target <= last; target += 1) { + append(target, assetInNotebook(asset, owner - target, pageHeight)); + } + } + pages.forEach((page, owner) => { + for (const asset of page.insertedElements ?? []) { + project(asset, owner, 240, 240, (target, local) => indexed[target].insertedElements.push(local)); + } + for (const asset of page.textBoxes ?? []) { + project(asset, owner, 200, 100, (target, local) => indexed[target].textBoxes.push(local)); + } + }); + return indexed; +} + +/** Export projections only: never persist these copies or give them new IDs. */ +export function assetsForPage(pages: NotebookPage[], target: number, pageHeight: number): PageAssets { + return indexAssetsByPage(pages, pageHeight)[target] ?? { insertedElements: [], textBoxes: [] }; +} + +export function duplicateAssetPosition( + asset: { x: number; y: number; width?: number; height?: number }, + owner: number, + pageWidth: number, + pageHeight: number, + pageCount: number, +) { + const globalY = owner * pageHeight + asset.y; + return { + x: Math.max(0, Math.min(pageWidth - (asset.width ?? 80), asset.x + 20)), + y: Math.max(0, Math.min( + pageCount * pageHeight - (asset.height ?? 80), globalY + 20, + )) - owner * pageHeight, + }; +}