react-native-mask-segment-c.../example/App.tsx

732 lines
22 KiB
TypeScript
Raw Normal View History

/**
* MaskSegmentCanvas business integration demo
*
* This file fully simulates the integration method of a real business project:
* - Only use public API through `import ... from 'react-native-mask-segment-canvas'`
* - Do not depend on the internal implementation of the library (do not import ../src)
* - Overlay: PNG preheating, status management, callback processing, Ref operations, draft recovery, error handling
*
* You can directly copy this file to your own React Native project as a reference.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import MaskSegmentCanvas, {
type BgrColor,
type MaskSegmentCanvasRef,
type MaskSegmentSession,
type MaskSegmentWatchState,
type MaskSemanticColor,
type PaintCallbackPayload,
type PipelinePreset,
type SavePaintResult,
MASK_SEMANTIC_COLORS,
BASEBOARD_SEMANTIC_NAME,
DEFAULT_PIPELINE_CONFIG,
DEFAULT_MASK_CONFIG,
DEFAULT_PAINT_CONFIG,
DEFAULT_INTERACTION_CONFIG,
prewarmPngBgrCacheAsync,
resolveAssetPath,
} from 'react-native-mask-segment-canvas';
// ============================================================================
// test images — two sets of example images, support switching
// replace your image path (file:// or http(s)://) when integrating into your business project
// ============================================================================
const TEST_IMAGE_GROUPS: Array<{
label: string;
origin: number;
mask: number;
originCacheName: string;
maskCacheName: string;
}> = [
{
label: 'picture group 1',
origin: require('./assets/origin.png'),
mask: require('./assets/mask.png'),
originCacheName: 'example_origin_g1.png',
maskCacheName: 'example_mask_g1.png',
},
{
label: 'picture group 2',
origin: require('./assets/origin-1.png'),
mask: require('./assets/mask-1.png'),
originCacheName: 'example_origin_g2.png',
maskCacheName: 'example_mask_g2.png',
},
];
// ============================================================================
// custom semantic colors example (gym scene)
// ============================================================================
const GYM_CUSTOM_COLORS: MaskSemanticColor[] = [
{ name: 'wall', hex: '#4363D8', bgr: { b: 216, g: 99, r: 67 } },
{ name: 'ceiling', hex: '#3CB44B', bgr: { b: 75, g: 180, r: 60 } },
{ name: 'floor', hex: '#E6194B', bgr: { b: 75, g: 25, r: 230 } },
{ name: 'window', hex: '#F58231', bgr: { b: 49, g: 130, r: 245 } },
{ name: 'door', hex: '#911EB4', bgr: { b: 180, g: 30, r: 145 } },
{ name: 'pillar', hex: '#46F0F0', bgr: { b: 240, g: 240, r: 70 } },
];
// ============================================================================
// preset brush colors (outside the bottom color bar, business can set through ref.setPaintColor)
// ============================================================================
const PAINT_PRESETS: Array<{ label: string; color: BgrColor }> = [
{ label: 'Ivory white', color: { b: 200, g: 230, r: 245 } },
{ label: 'Yellow', color: { b: 150, g: 220, r: 245 } },
{ label: 'Light gray', color: { b: 180, g: 180, r: 180 } },
{ label: 'Light blue', color: { b: 220, g: 200, r: 170 } },
];
// ============================================================================
// watchState tools
// ============================================================================
const INTERACTIVE_STATES: MaskSegmentWatchState[] = [
'interactive',
'mask_paths_ready',
];
// ============================================================================
// main page
// ============================================================================
function App(): React.JSX.Element {
const canvasRef = useRef<MaskSegmentCanvasRef>(null);
// --------------------------------------------------------------------------
// State
// --------------------------------------------------------------------------
const [imagePaths, setImagePaths] = useState<{
origin: string;
mask: string;
} | null>(null);
const [pathsError, setPathsError] = useState('');
const [watchState, setWatchState] = useState<MaskSegmentWatchState | ''>('');
const [watchDetail, setWatchDetail] = useState<Record<string, unknown>>({});
const [errorMessage, setErrorMessage] = useState('');
const [toastMessage, setToastMessage] = useState('');
const [saveResult, setSaveResult] = useState<SavePaintResult | null>(null);
const [sessionDraft] = useState<MaskSegmentSession | null>(null);
// Demo mode
const [useCustomColors, setUseCustomColors] = useState(false);
const [splitWalls, setSplitWalls] = useState(false);
const [pipelinePreset, setPipelinePreset] = useState<PipelinePreset>('medium');
const [groupIndex, setGroupIndex] = useState(0);
// --------------------------------------------------------------------------
// derived state
// --------------------------------------------------------------------------
const isInteractive = INTERACTIVE_STATES.includes(
watchState as MaskSegmentWatchState,
);
const isOutlineReady = watchState === 'mask_paths_ready';
const isInitLoading =
imagePaths != null &&
watchState !== '' &&
!INTERACTIVE_STATES.includes(watchState as MaskSegmentWatchState) &&
watchState !== 'error';
const semanticColors = useCustomColors ? GYM_CUSTOM_COLORS : MASK_SEMANTIC_COLORS;
// --------------------------------------------------------------------------
// Init: resolve test image paths (require → local PNG cache path)
// --------------------------------------------------------------------------
useEffect(() => {
let cancelled = false;
void (async () => {
try {
setWatchState('');
setWatchDetail({});
setErrorMessage('');
setSaveResult(null);
setPathsError('');
setImagePaths(null);
const group = TEST_IMAGE_GROUPS[groupIndex];
const [origin, mask] = await Promise.all([
resolveAssetPath(group.origin, group.originCacheName),
resolveAssetPath(group.mask, group.maskCacheName),
]);
await prewarmPngBgrCacheAsync([origin, mask]);
if (!cancelled) {
setImagePaths({ origin, mask });
}
} catch (e) {
if (!cancelled) {
setPathsError(e instanceof Error ? e.message : String(e));
}
}
})();
return () => {
cancelled = true;
};
}, [groupIndex]);
// --------------------------------------------------------------------------
// Toast message
// --------------------------------------------------------------------------
const showToast = useCallback((msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(''), 2500);
}, []);
// --------------------------------------------------------------------------
// onWatch callback
// --------------------------------------------------------------------------
const handleWatch = useCallback(
(
state: MaskSegmentWatchState,
durationMs: number,
detail?: Record<string, unknown>,
) => {
setWatchState(state);
if (detail) setWatchDetail(detail);
console.log(
`[Example onWatch] ${state} ${durationMs.toFixed(0)}ms`,
detail ?? '',
);
},
[],
);
// --------------------------------------------------------------------------
// onPaintCallback — handle paint success / brush not selected two scenarios
// --------------------------------------------------------------------------
const handlePaintCallback = useCallback((payload: PaintCallbackPayload) => {
if (payload.kind === 'brush_required') {
// user did not select a brush, the business side pops up a prompt to guide selection of color
showToast(payload.hint);
console.log('[Example] Need to select a brush:', payload.regionName);
return;
}
// paint success
console.log(
'[Example] Paint success:',
payload.regionName,
`(${payload.regionId})`,
payload.color,
);
}, [showToast]);
// --------------------------------------------------------------------------
// onError callback
// --------------------------------------------------------------------------
const handleError = useCallback((message: string) => {
setErrorMessage(message);
setWatchState('error');
}, []);
// --------------------------------------------------------------------------
// Ref operations encapsulation
// --------------------------------------------------------------------------
const handleSave = useCallback(async () => {
if (!isInteractive) return;
try {
const result = await canvasRef.current?.save();
if (result) {
setSaveResult(result);
Alert.alert('Save success', `Path: ${result.filePath}\nPainted ${result.paintedCount} regions`);
}
} catch (e) {
Alert.alert('Save failed', e instanceof Error ? e.message : String(e));
}
}, [isInteractive]);
const handleReset = useCallback(() => canvasRef.current?.reset(), []);
const handleSwap = useCallback(() => canvasRef.current?.swap(), []);
const handleClearAll = useCallback(() => {
canvasRef.current?.clearAllPaint();
showToast('All paint cleared');
}, [showToast]);
const handleExportSession = useCallback(() => {
const session = canvasRef.current?.session();
if (session) {
console.log('[Example] Session snapshot:', JSON.stringify(session, null, 2));
Alert.alert(
'Session snapshot',
`Painted ${session.painted.length} regions\nCan be stored in MMKV / AsyncStorage to implement draft recovery`,
);
}
}, []);
const handleSetPaintColor = useCallback(
(color: BgrColor, label: string) => {
canvasRef.current?.setPaintColor(color, { preset: label });
showToast(`Selected brush: ${label}`);
},
[showToast],
);
// --------------------------------------------------------------------------
// render: error / loading / ready
// --------------------------------------------------------------------------
if (pathsError) {
return (
<SafeAreaView style={styles.root}>
<View style={styles.center}>
<Text style={styles.errorText}>Image loading failed</Text>
<Text style={styles.errorDetail}>{pathsError}</Text>
</View>
</SafeAreaView>
);
}
if (!imagePaths) {
return (
<SafeAreaView style={styles.root}>
<View style={styles.center}>
<ActivityIndicator size="large" color="#4363D8" />
<Text style={styles.loadingText}>Preheating PNG cache</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.root}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
{/* top: status + mode switch */}
<View style={styles.topBar}>
<View style={styles.topBarRow}>
<Text style={styles.statusLabel}>
Status:{' '}
<Text
style={[
styles.statusValue,
isInteractive && styles.statusReady,
watchState === 'error' && styles.statusError,
]}
>
{watchState || 'Initializing…'}
</Text>
{isOutlineReady ? ' · Carousel ready' : ''}
{isInteractive && !isOutlineReady ? ' · Outline loading' : ''}
</Text>
<Text style={styles.regionCount}>
{watchDetail.regionCount != null
? `${watchDetail.regionCount} partitions`
: ''}
</Text>
</View>
{/* mode switch */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.modeRow}
contentContainerStyle={styles.modeRowContent}
>
<TouchableOpacity
style={[styles.modeChip, !useCustomColors && styles.modeChipActive]}
onPress={() => setUseCustomColors(false)}
>
<Text style={[styles.modeChipText, !useCustomColors && styles.modeChipTextActive]}>
Default color palette
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modeChip, useCustomColors && styles.modeChipActive]}
onPress={() => setUseCustomColors(true)}
>
<Text style={[styles.modeChipText, useCustomColors && styles.modeChipTextActive]}>
Custom color palette
</Text>
</TouchableOpacity>
<Text style={styles.modeDivider}>|</Text>
{TEST_IMAGE_GROUPS.map((group, idx) => (
<TouchableOpacity
key={group.label}
style={[styles.modeChip, groupIndex === idx && styles.modeChipActive]}
onPress={() => setGroupIndex(idx)}
>
<Text style={[styles.modeChipText, groupIndex === idx && styles.modeChipTextActive]}>
{group.label}
</Text>
</TouchableOpacity>
))}
<Text style={styles.modeDivider}>|</Text>
{(['low', 'medium', 'high'] as PipelinePreset[]).map(p => (
<TouchableOpacity
key={p}
style={[styles.modeChip, pipelinePreset === p && styles.modeChipActive]}
onPress={() => setPipelinePreset(p)}
>
<Text style={[styles.modeChipText, pipelinePreset === p && styles.modeChipTextActive]}>
{p === 'low' ? 'Low precision' : p === 'medium' ? 'Medium precision' : 'High precision'}
</Text>
</TouchableOpacity>
))}
<Text style={styles.modeDivider}>|</Text>
<TouchableOpacity
style={[styles.modeChip, splitWalls && styles.modeChipActive]}
onPress={() => setSplitWalls(v => !v)}
>
<Text style={[styles.modeChipText, splitWalls && styles.modeChipTextActive]}>
(split walls)
</Text>
</TouchableOpacity>
</ScrollView>
</View>
{/* canvas */}
<View style={styles.canvasHost}>
<MaskSegmentCanvas
key={`image-group-${groupIndex}-split-${splitWalls ? 1 : 0}`}
ref={canvasRef}
style={styles.canvas}
originUrl={imagePaths.origin}
maskUrl={imagePaths.mask}
semanticColors={semanticColors}
regionOutlineColor="rgba(20, 120, 235, 0.58)"
pipelinePreset={pipelinePreset}
maskConfig={{
...DEFAULT_MASK_CONFIG,
maxRegionColors: 6,
splitWalls,
}}
paintConfig={{
...DEFAULT_PAINT_CONFIG,
colorBaseOpacity: 0.88,
}}
interactionConfig={{
...DEFAULT_INTERACTION_CONFIG,
initRegionFlashMs: 1000,
enableInitRegionFlash: true,
}}
disabled={!isInteractive}
initialSession={sessionDraft ?? undefined}
onWatch={handleWatch}
onPaintCallback={handlePaintCallback}
onError={handleError}
/>
{/* initialization loading mask */}
{isInitLoading && (
<View style={styles.initOverlay} pointerEvents="none">
<ActivityIndicator size="small" color="#4363D8" />
<Text style={styles.initOverlayText}>
Initializing: {watchState}
</Text>
</View>
)}
</View>
{/* Toast */}
{toastMessage ? (
<View style={styles.toast} pointerEvents="none">
<Text style={styles.toastText}>{toastMessage}</Text>
</View>
) : null}
{/* bottom: business operation bar / Ref method demonstration */}
<View style={styles.bottomBar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.bottomBarContent}
>
{/* preset brush (replace bottom color bar) */}
<Text style={styles.sectionLabel}>Preset brush:</Text>
{PAINT_PRESETS.map(p => (
<TouchableOpacity
key={p.label}
style={[styles.paintBtn, { backgroundColor: `rgb(${p.color.r},${p.color.g},${p.color.b})` }]}
onPress={() => handleSetPaintColor(p.color, p.label)}
disabled={!isInteractive}
>
<Text style={styles.paintBtnText}>{p.label}</Text>
</TouchableOpacity>
))}
<View style={styles.divider} />
{/* Ref operations */}
<Text style={styles.sectionLabel}>Operations:</Text>
<TouchableOpacity
style={[styles.actionBtn, styles.actionBtnDanger]}
onPress={handleReset}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}>Undo</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={handleSwap}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}>Compare</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={handleClearAll}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}>Clear</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.actionBtnPrimary]}
onPress={handleSave}
disabled={!isInteractive}
>
<Text style={styles.actionBtnTextPrimary}>Save</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={handleExportSession}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}>Export session</Text>
</TouchableOpacity>
</ScrollView>
</View>
{/* error display */}
{errorMessage ? (
<View style={styles.errorBar}>
<Text style={styles.errorBarText}>Error: {errorMessage}</Text>
</View>
) : null}
</SafeAreaView>
);
}
// ============================================================================
// styles
// ============================================================================
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: '#fff',
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
loadingText: {
marginTop: 12,
color: '#888',
fontSize: 14,
},
errorText: {
color: '#c33',
fontSize: 18,
fontWeight: '600',
},
errorDetail: {
marginTop: 8,
color: '#999',
fontSize: 13,
textAlign: 'center',
},
// top status bar
topBar: {
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 4,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e8e8e8',
backgroundColor: '#fafafa',
},
topBarRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statusLabel: {
fontSize: 12,
color: '#888',
},
statusValue: {
fontWeight: '600',
color: '#555',
},
statusReady: {
color: '#2a7',
},
statusError: {
color: '#c33',
},
regionCount: {
fontSize: 11,
color: '#aaa',
},
modeRow: {
marginTop: 6,
},
modeRowContent: {
alignItems: 'center',
gap: 6,
},
modeChip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: '#eee',
},
modeChipActive: {
backgroundColor: '#4363D8',
},
modeChipText: {
fontSize: 11,
color: '#666',
},
modeChipTextActive: {
color: '#fff',
fontWeight: '600',
},
modeDivider: {
color: '#ddd',
fontSize: 11,
marginHorizontal: 2,
},
// canvas
canvasHost: {
flex: 1,
position: 'relative',
height: 280,
},
canvas: {
flex: 1,
},
initOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
gap: 8,
},
initOverlayText: {
color: '#888',
fontSize: 13,
},
// Toast
toast: {
position: 'absolute',
top: 120,
left: 20,
right: 20,
alignItems: 'center',
zIndex: 999,
},
toastText: {
backgroundColor: 'rgba(0,0,0,0.78)',
color: '#fff',
fontSize: 13,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
overflow: 'hidden',
},
// bottom operation bar
bottomBar: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#e8e8e8',
backgroundColor: '#fafafa',
paddingVertical: 8,
},
bottomBarContent: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
gap: 8,
},
sectionLabel: {
fontSize: 12,
color: '#999',
fontWeight: '600',
},
divider: {
width: 1,
height: 20,
backgroundColor: '#e0e0e0',
marginHorizontal: 4,
},
// brush button
paintBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#ddd',
},
paintBtnText: {
fontSize: 11,
color: '#333',
fontWeight: '600',
textShadowColor: 'rgba(255,255,255,0.6)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
// operation button
actionBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#f0f0f0',
borderWidth: 1,
borderColor: '#ddd',
},
actionBtnPrimary: {
backgroundColor: '#4363D8',
borderColor: '#4363D8',
},
actionBtnDanger: {
borderColor: '#e88',
},
actionBtnText: {
fontSize: 12,
color: '#555',
fontWeight: '500',
},
actionBtnTextPrimary: {
fontSize: 12,
color: '#fff',
fontWeight: '600',
},
// error bar
errorBar: {
backgroundColor: '#fff0f0',
paddingHorizontal: 12,
paddingVertical: 6,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#fcc',
},
errorBarText: {
fontSize: 12,
color: '#c33',
},
});
export default function Root(): React.JSX.Element {
return (
<SafeAreaProvider>
<App />
</SafeAreaProvider>
);
}