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

728 lines
22 KiB
TypeScript
Raw Normal View History

/**
* MaskSegmentCanvas Demo
*
*
* - `import ... from 'react-native-mask-segment-canvas'` 使 API
* - import ../src
* - PNG Ref 稿
*
* React Native
*/
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';
// ============================================================================
// 测试图片 — 两组示例图,支持切换
// 业务项目接入时替换为你的图片路径file:// 或 http(s)://
// ============================================================================
const TEST_IMAGE_GROUPS: Array<{
label: string;
origin: number;
mask: number;
originCacheName: string;
maskCacheName: string;
}> = [
{
label: '图片组 1',
origin: require('./assets/origin.png'),
mask: require('./assets/mask.png'),
originCacheName: 'example_origin_g1.png',
maskCacheName: 'example_mask_g1.png',
},
{
label: '图片组 2',
origin: require('./assets/origin-1.png'),
mask: require('./assets/mask-1.png'),
originCacheName: 'example_origin_g2.png',
maskCacheName: 'example_mask_g2.png',
},
];
// ============================================================================
// 自定义语义色表示例(健身房场景)
// ============================================================================
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 } },
];
// ============================================================================
// 预设笔刷色(底部色条之外,业务可通过 ref.setPaintColor 设置)
// ============================================================================
const PAINT_PRESETS: Array<{ label: string; color: BgrColor }> = [
{ label: '象牙白', color: { b: 200, g: 230, r: 245 } },
{ label: '米黄', color: { b: 150, g: 220, r: 245 } },
{ label: '浅灰', color: { b: 180, g: 180, r: 180 } },
{ label: '淡蓝', color: { b: 220, g: 200, r: 170 } },
];
// ============================================================================
// watchState 工具
// ============================================================================
const INTERACTIVE_STATES: MaskSegmentWatchState[] = [
'interactive',
'mask_paths_ready',
];
// ============================================================================
// 主页面
// ============================================================================
function App(): React.JSX.Element {
const canvasRef = useRef<MaskSegmentCanvasRef>(null);
// --------------------------------------------------------------------------
// 状态
// --------------------------------------------------------------------------
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 模式
const [useCustomColors, setUseCustomColors] = useState(false);
const [pipelinePreset, setPipelinePreset] = useState<PipelinePreset>('medium');
const [groupIndex, setGroupIndex] = useState(0);
// --------------------------------------------------------------------------
// 派生状态
// --------------------------------------------------------------------------
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;
// --------------------------------------------------------------------------
// 初始化解析测试图路径require → 本地 PNG 缓存路径)
// --------------------------------------------------------------------------
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 提示
// --------------------------------------------------------------------------
const showToast = useCallback((msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(''), 2500);
}, []);
// --------------------------------------------------------------------------
// onWatch 回调
// --------------------------------------------------------------------------
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 — 处理上色成功 / 未选笔刷两种场景
// --------------------------------------------------------------------------
const handlePaintCallback = useCallback((payload: PaintCallbackPayload) => {
if (payload.kind === 'brush_required') {
// 用户未选笔刷时点击了分区,业务侧弹提示引导选色
showToast(payload.hint);
console.log('[Example] 需选笔刷:', payload.regionName);
return;
}
// 上色成功
console.log(
'[Example] 上色成功:',
payload.regionName,
`(${payload.regionId})`,
payload.color,
);
}, [showToast]);
// --------------------------------------------------------------------------
// onError 回调
// --------------------------------------------------------------------------
const handleError = useCallback((message: string) => {
setErrorMessage(message);
setWatchState('error');
}, []);
// --------------------------------------------------------------------------
// Ref 操作封装
// --------------------------------------------------------------------------
const handleSave = useCallback(async () => {
if (!isInteractive) return;
try {
const result = await canvasRef.current?.save();
if (result) {
setSaveResult(result);
Alert.alert('保存成功', `路径: ${result.filePath}\n已上色 ${result.paintedCount} 个区域`);
}
} catch (e) {
Alert.alert('保存失败', 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('已清空全部上色');
}, [showToast]);
const handleExportSession = useCallback(() => {
const session = canvasRef.current?.session();
if (session) {
console.log('[Example] 会话快照:', JSON.stringify(session, null, 2));
Alert.alert(
'会话快照',
`已上色 ${session.painted.length} 个区域\n可存入 MMKV / AsyncStorage 实现草稿恢复`,
);
}
}, []);
const handleSetPaintColor = useCallback(
(color: BgrColor, label: string) => {
canvasRef.current?.setPaintColor(color, { preset: label });
showToast(`已选择笔刷: ${label}`);
},
[showToast],
);
// --------------------------------------------------------------------------
// 渲染:错误 / 加载 / 就绪
// --------------------------------------------------------------------------
if (pathsError) {
return (
<SafeAreaView style={styles.root}>
<View style={styles.center}>
<Text style={styles.errorText}></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}> PNG </Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.root}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
{/* 顶部:状态 + 模式切换 */}
<View style={styles.topBar}>
<View style={styles.topBarRow}>
<Text style={styles.statusLabel}>
:{' '}
<Text
style={[
styles.statusValue,
isInteractive && styles.statusReady,
watchState === 'error' && styles.statusError,
]}
>
{watchState || '初始化…'}
</Text>
{isOutlineReady ? ' · 轮播就绪' : ''}
{isInteractive && !isOutlineReady ? ' · 轮廓加载中' : ''}
</Text>
<Text style={styles.regionCount}>
{watchDetail.regionCount != null
? `${watchDetail.regionCount} 个分区`
: ''}
</Text>
</View>
{/* 模式切换 */}
<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]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modeChip, useCustomColors && styles.modeChipActive]}
onPress={() => setUseCustomColors(true)}
>
<Text style={[styles.modeChipText, useCustomColors && styles.modeChipTextActive]}>
</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' ? '低精度' : p === 'medium' ? '中精度' : '高精度'}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* 画布 */}
<View style={styles.canvasHost}>
<MaskSegmentCanvas
key={`image-group-${groupIndex}`}
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,
}}
paintConfig={{
...DEFAULT_PAINT_CONFIG,
colorBaseOpacity: 0.88,
}}
interactionConfig={{
...DEFAULT_INTERACTION_CONFIG,
initRegionFlashMs: 1000,
enableInitRegionFlash: true,
}}
showDebugPickers={false}
showToolbar={false}
showColorBar
showStatusRow={false}
showOverlayButtons
disabled={!isInteractive}
initialSession={sessionDraft ?? undefined}
undoButtonText="撤销"
compareButtonText="对比原图"
compareExitButtonText="退出对比"
onWatch={handleWatch}
onPaintCallback={handlePaintCallback}
onError={handleError}
/>
{/* 初始化加载遮罩 */}
{isInitLoading && (
<View style={styles.initOverlay} pointerEvents="none">
<ActivityIndicator size="small" color="#4363D8" />
<Text style={styles.initOverlayText}>
{watchState}
</Text>
</View>
)}
</View>
{/* Toast */}
{toastMessage ? (
<View style={styles.toast} pointerEvents="none">
<Text style={styles.toastText}>{toastMessage}</Text>
</View>
) : null}
{/* 底部:业务操作栏 / Ref 方法演示 */}
<View style={styles.bottomBar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.bottomBarContent}
>
{/* 预设笔刷(替代底部色条) */}
<Text style={styles.sectionLabel}>:</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 操作 */}
<Text style={styles.sectionLabel}>:</Text>
<TouchableOpacity
style={[styles.actionBtn, styles.actionBtnDanger]}
onPress={handleReset}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={handleSwap}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={handleClearAll}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.actionBtnPrimary]}
onPress={handleSave}
disabled={!isInteractive}
>
<Text style={styles.actionBtnTextPrimary}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={handleExportSession}
disabled={!isInteractive}
>
<Text style={styles.actionBtnText}></Text>
</TouchableOpacity>
</ScrollView>
</View>
{/* 错误展示 */}
{errorMessage ? (
<View style={styles.errorBar}>
<Text style={styles.errorBarText}>: {errorMessage}</Text>
</View>
) : null}
</SafeAreaView>
);
}
// ============================================================================
// 样式
// ============================================================================
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',
},
// 顶部状态栏
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,
},
// 画布
canvasHost: {
flex: 1,
position: 'relative',
},
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',
},
// 底部操作栏
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,
},
// 笔刷按钮
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,
},
// 操作按钮
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',
},
// 错误条
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>
);
}