diff --git a/ios/Fabric/NutrientView.mm b/ios/Fabric/NutrientView.mm index 8d2cd831..aaf7573c 100644 --- a/ios/Fabric/NutrientView.mm +++ b/ios/Fabric/NutrientView.mm @@ -319,6 +319,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _view.componentID = [self.nativeId integerValue]; auto newProps = std::static_pointer_cast(props); + auto oldViewProps = std::static_pointer_cast(oldProps); if (newProps != nullptr) { // Parse configuration first so remoteDocumentConfiguration is available when applying document NSDictionary *jsonConfig = nil; @@ -327,8 +328,16 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & jsonConfig = [NutrientFabricUtils dictionaryFromJSONString:_configurationJSONString]; } - // Basic document props - if (!newProps->document.empty()) { + // Basic document props. + // + // Only (re)apply the document when the path actually changes. Fabric calls + // updateProps with the full prop set on every re-render, so applying the + // document unconditionally here would create a fresh PSPDFDocument and + // reload the controller on *any* prop change (e.g. a dynamic + // menuItemGrouping driven by React state), discarding unsaved in-memory + // annotations. Mirrors the document-identity guard used on Android. + BOOL documentChanged = (oldViewProps == nullptr) || (newProps->document != oldViewProps->document); + if (!newProps->document.empty() && documentChanged) { _document = RCTNSStringFromString(newProps->document); NSNumber *reference = [NSNumber numberWithInteger:[self.nativeId integerValue]]; NSDictionary *remoteConfig = jsonConfig[@"remoteDocumentConfiguration"]; diff --git a/samples/Catalog/Catalog.tsx b/samples/Catalog/Catalog.tsx index 51d3ea9c..1179798f 100644 --- a/samples/Catalog/Catalog.tsx +++ b/samples/Catalog/Catalog.tsx @@ -25,6 +25,7 @@ import { ManualSave } from './examples/ManualSave'; import Measurement from './examples/Measurement'; import { OpenImageDocument } from './examples/OpenImageDocument'; import { ProgrammaticAnnotations } from './examples/ProgrammaticAnnotations'; +import { SwitchAnnotationTools } from './examples/SwitchAnnotationTools'; import { ProgrammaticFormFilling } from './examples/ProgrammaticFormFilling'; import { NutrientViewComponent } from './examples/NutrientViewComponent'; import { SaveAs } from './examples/SaveAs'; @@ -83,6 +84,10 @@ class Catalog extends React.Component { component={OpenRemoteDocument} /> + diff --git a/samples/Catalog/ExamplesNavigationMenu.tsx b/samples/Catalog/ExamplesNavigationMenu.tsx index d75784c0..7ef8decb 100644 --- a/samples/Catalog/ExamplesNavigationMenu.tsx +++ b/samples/Catalog/ExamplesNavigationMenu.tsx @@ -277,6 +277,17 @@ export default [ title: 'AI Assistant', }); }, + }, + { + key: 'item26', + name: 'Switch Annotation Tools', + description: + 'Switch between annotation creation tools without losing unsaved annotations. Toggle to compare a dynamic vs. static menuItemGrouping.', + action: (component: any) => { + extractFromAssetsIfMissing(exampleDocumentName, function () { + component.props.navigation.push('SwitchAnnotationTools'); + }); + }, } ]; diff --git a/samples/Catalog/examples/SwitchAnnotationTools.tsx b/samples/Catalog/examples/SwitchAnnotationTools.tsx new file mode 100644 index 00000000..c1ee63de --- /dev/null +++ b/samples/Catalog/examples/SwitchAnnotationTools.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { + processColor, + Switch, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import NutrientView, { Annotation } from '@nutrient-sdk/react-native'; + +import { pspdfkitColor, writableDocumentPath } from '../configuration/Constants'; +import { BaseExampleAutoHidingHeaderComponent } from '../helpers/BaseExampleAutoHidingHeaderComponent'; + +type Tool = 'ink' | 'highlight' | 'note' | 'signature'; + +const TOOL_TO_ANNOTATION: Record = { + ink: Annotation.Type.INK, + highlight: Annotation.Type.HIGHLIGHT, + note: Annotation.Type.NOTE, + signature: Annotation.Type.SIGNATURE, +}; + +// A single, stable grouping defined once at module scope. Because this +// reference and its value never change, switching tools never updates a +// NutrientView prop, so the native view is never re-configured/reloaded and +// unsaved annotations are preserved. +const STATIC_MENU_ITEM_GROUPING = ['ink', 'highlight', 'note', 'signature']; + +interface IState { + activeTool: Tool; + // When true we filter menuItemGrouping down to the active tool on every + // switch (rebuilding the prop each time — reproduces the annotation loss). + // When false we keep a single static grouping (the recommended approach). + dynamicGrouping: boolean; +} + +/** + * Switching annotation creation tools without saving. + * + * Deriving `menuItemGrouping` from React state and rebuilding it on every tool + * switch changes a NutrientView prop, which can make the native view re-apply + * its configuration / reload and discard the in-memory (unsaved) annotation. + * + * Toggle "Dynamic grouping" ON to see that behavior, OFF to use a static + * grouping (with imperative enter/exit calls) that keeps the annotation. + */ +export class SwitchAnnotationTools extends BaseExampleAutoHidingHeaderComponent { + pdfRef: React.RefObject; + + constructor(props: any) { + super(props); + this.pdfRef = React.createRef(); + this.state = { + // Default to the static grouping (recommended). Toggle the switch ON to + // see the annotation loss caused by a dynamic, state-driven grouping. + activeTool: 'ink', + dynamicGrouping: false, + }; + } + + // Customer's pattern: grouping filtered to the active tool only. + private dynamicMenuItemGrouping() { + return (['ink', 'highlight', 'note', 'signature'] as Tool[]).filter( + item => item === this.state.activeTool, + ); + } + + // Option 1 fix: always the same stable grouping (see STATIC_MENU_ITEM_GROUPING). + private staticMenuItemGrouping() { + return STATIC_MENU_ITEM_GROUPING; + } + + private async switchTool(tool: Tool) { + // Commit whatever is being drawn and leave the current mode. + await this.pdfRef.current?.exitCurrentlyActiveMode(); + this.setState({ activeTool: tool }); + setTimeout(async () => { + await this.pdfRef.current?.enterAnnotationCreationMode( + TOOL_TO_ANNOTATION[tool], + ); + }, 250); + } + + override render() { + const tools: Tool[] = ['ink', 'highlight', 'note', 'signature']; + return ( + + + {this.renderWithSafeArea(insets => ( + + + Dynamic grouping (repro) + this.setState({ dynamicGrouping: v })} + /> + + + {tools.map(tool => ( + this.switchTool(tool)}> + {tool} + + ))} + + this.pdfRef.current?.getDocument().save()}> + Save + + + ))} + + ); + } +} + +const styles = { + flex: { flex: 1 }, + pdfColor: { flex: 1, color: pspdfkitColor }, + controls: { + backgroundColor: '#f7f7f7', + paddingHorizontal: 10, + paddingTop: 8, + }, + row: { + flexDirection: 'row' as 'row', + alignItems: 'center' as 'center', + justifyContent: 'space-between' as 'space-between', + marginVertical: 6, + }, + label: { fontSize: 15, color: '#333' }, + button: { + flex: 1, + paddingVertical: 10, + marginHorizontal: 3, + borderRadius: 5, + backgroundColor: '#e6e6e6', + }, + buttonActive: { backgroundColor: '#cfe3ff' }, + saveButton: { + paddingVertical: 12, + marginVertical: 6, + borderRadius: 5, + backgroundColor: '#f0f0f0', + }, + buttonText: { + fontSize: 15, + color: pspdfkitColor, + textAlign: 'center' as 'center', + }, +};