728 lines
22 KiB
TypeScript
728 lines
22 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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>
|
|||
|
|
);
|
|||
|
|
}
|