Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions ios/Fabric/NutrientView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<const NutrientViewProps>(props);
auto oldViewProps = std::static_pointer_cast<const NutrientViewProps>(oldProps);
if (newProps != nullptr) {
// Parse configuration first so remoteDocumentConfiguration is available when applying document
NSDictionary *jsonConfig = nil;
Expand All @@ -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"];
Expand Down
5 changes: 5 additions & 0 deletions samples/Catalog/Catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +84,10 @@ class Catalog extends React.Component {
component={OpenRemoteDocument}
/>
<Stack.Screen name="ManualSave" component={ManualSave} />
<Stack.Screen
name="SwitchAnnotationTools"
component={SwitchAnnotationTools}
/>
<Stack.Screen name="SaveAs" component={SaveAs} />
<Stack.Screen name="EventListeners" component={EventListeners} />
<Stack.Screen name="StateChange" component={StateChange} />
Expand Down
11 changes: 11 additions & 0 deletions samples/Catalog/ExamplesNavigationMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
},
}
];

Expand Down
171 changes: 171 additions & 0 deletions samples/Catalog/examples/SwitchAnnotationTools.tsx
Original file line number Diff line number Diff line change
@@ -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<Tool, Annotation.Type> = {
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<NutrientView | null>;

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 (
<View style={styles.flex}>
<NutrientView
ref={this.pdfRef}
document={writableDocumentPath}
disableAutomaticSaving={true}
configuration={{
iOSBackgroundColor: processColor('lightgrey'),
}}
menuItemGrouping={
this.state.dynamicGrouping
? this.dynamicMenuItemGrouping()
: this.staticMenuItemGrouping()
}
style={styles.pdfColor}
/>
{this.renderWithSafeArea(insets => (
<View
style={[styles.controls, { paddingBottom: insets.bottom + 8 }]}>
<View style={styles.row}>
<Text style={styles.label}>Dynamic grouping (repro)</Text>
<Switch
value={this.state.dynamicGrouping}
onValueChange={v => this.setState({ dynamicGrouping: v })}
/>
</View>
<View style={styles.row}>
{tools.map(tool => (
<TouchableOpacity
key={tool}
style={[
styles.button,
this.state.activeTool === tool && styles.buttonActive,
]}
onPress={() => this.switchTool(tool)}>
<Text style={styles.buttonText}>{tool}</Text>
</TouchableOpacity>
))}
</View>
<TouchableOpacity
style={styles.saveButton}
onPress={() => this.pdfRef.current?.getDocument().save()}>
<Text style={styles.buttonText}>Save</Text>
</TouchableOpacity>
</View>
))}
</View>
);
}
}

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',
},
};