feat(business): enhance block editor with segment conversion and data persistence
- Implement `segmentsToBlocks` utility to convert message segments into editable blocks. - Update `BlockEditor` and `useBlockEditor` to support initial block loading and improved image upload handling. - Add logic to `CreatePostScreen` to automatically switch to long-post mode when image segments are detected and preserve data when toggling modes. - Update `AboutScreen` to make the ICP filing number clickable. - Refactor mapper imports to use the updated data model paths.
This commit is contained in:
@@ -3,12 +3,14 @@ import { View, ScrollView, StyleSheet } from 'react-native';
|
||||
|
||||
import { useAppColors, spacing } from '../../../theme';
|
||||
import { MessageSegment } from '../../../types';
|
||||
import { EditorBlock } from './blockEditorTypes';
|
||||
import TextBlockInput from './TextBlockInput';
|
||||
import ImageBlockView from './ImageBlockView';
|
||||
import { useBlockEditor } from './useBlockEditor';
|
||||
|
||||
export interface BlockEditorHandle {
|
||||
insertImage: () => Promise<void>;
|
||||
insertCameraPhoto: () => Promise<void>;
|
||||
insertTextAtCursor: (text: string) => void;
|
||||
getSegments: () => MessageSegment[];
|
||||
getContent: () => string;
|
||||
@@ -22,15 +24,17 @@ interface BlockEditorProps {
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
onContentChange?: (content: string) => void;
|
||||
initialBlocks?: EditorBlock[];
|
||||
}
|
||||
|
||||
const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
||||
({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange }, ref) => {
|
||||
({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange, initialBlocks }, ref) => {
|
||||
const colors = useAppColors();
|
||||
const editor = useBlockEditor();
|
||||
const editor = useBlockEditor(initialBlocks);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertImage: editor.insertImage,
|
||||
insertCameraPhoto: editor.insertCameraPhoto,
|
||||
insertTextAtCursor: editor.insertTextAtCursor,
|
||||
getSegments: editor.getSegments,
|
||||
getContent: editor.getContent,
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import { MessageSegment } from '../../../types';
|
||||
import { EditorBlock, blockTextToSegments } from './blockEditorTypes';
|
||||
import { EditorBlock, TextBlock, ImageBlock, blockTextToSegments, generateBlockId } from './blockEditorTypes';
|
||||
|
||||
export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
|
||||
if (!segments || segments.length === 0) return [];
|
||||
|
||||
const blocks: EditorBlock[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'image' && seg.data?.url) {
|
||||
const imageBlock: ImageBlock = {
|
||||
id: generateBlockId(),
|
||||
type: 'image',
|
||||
localUri: seg.data.url,
|
||||
remoteUrl: seg.data.url,
|
||||
uploading: false,
|
||||
width: seg.data.width,
|
||||
height: seg.data.height,
|
||||
};
|
||||
blocks.push(imageBlock);
|
||||
} else if (seg.type === 'text') {
|
||||
const text = seg.data?.text || seg.data?.content || '';
|
||||
if (text) {
|
||||
const textBlock: TextBlock = {
|
||||
id: generateBlockId(),
|
||||
type: 'text',
|
||||
text,
|
||||
activeMentions: new Map(),
|
||||
};
|
||||
blocks.push(textBlock);
|
||||
}
|
||||
}
|
||||
// Skip at/other segment types — mentions from long posts are not editable in block editor
|
||||
}
|
||||
|
||||
// Ensure at least one text block
|
||||
if (blocks.length === 0) {
|
||||
blocks.push({ id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function blocksToSegments(blocks: EditorBlock[]): MessageSegment[] {
|
||||
const segments: MessageSegment[] = [];
|
||||
|
||||
@@ -19,8 +19,8 @@ interface MentionData {
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export function useBlockEditor() {
|
||||
const [blocks, setBlocks] = useState<EditorBlock[]>([createInitialTextBlock()]);
|
||||
export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
const [blocks, setBlocks] = useState<EditorBlock[]>(initialBlocks && initialBlocks.length > 0 ? initialBlocks : [createInitialTextBlock()]);
|
||||
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
|
||||
const inputRefs = useRef<Map<string, TextInput>>(new Map());
|
||||
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
|
||||
@@ -87,21 +87,56 @@ export function useBlockEditor() {
|
||||
});
|
||||
}, [focusBlock]);
|
||||
|
||||
const insertImage = useCallback(async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
||||
return;
|
||||
}
|
||||
const uploadImageByLocalUri = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: 0,
|
||||
quality: 1,
|
||||
});
|
||||
if (uploadResult) {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? {
|
||||
...b,
|
||||
remoteUrl: uploadResult.url,
|
||||
uploading: false,
|
||||
width: uploadResult.width ?? b.width,
|
||||
height: uploadResult.height ?? b.height,
|
||||
}
|
||||
: b
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Upload failed
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
}
|
||||
} catch {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const insertImagesFromAssets = useCallback(
|
||||
(assets: ImagePicker.ImagePickerAsset[]) => {
|
||||
if (!assets.length) return;
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
setBlocks(prev => {
|
||||
let updated = [...prev];
|
||||
let currentActiveId = activeBlockId;
|
||||
@@ -111,7 +146,7 @@ export function useBlockEditor() {
|
||||
insertAfterIndex = updated.length - 1;
|
||||
}
|
||||
|
||||
for (const asset of result.assets) {
|
||||
for (const asset of assets) {
|
||||
const activeBlock = updated[insertAfterIndex];
|
||||
const cursorPos = activeBlock && activeBlock.type === 'text'
|
||||
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
||||
@@ -170,59 +205,49 @@ export function useBlockEditor() {
|
||||
});
|
||||
|
||||
// Start async uploads
|
||||
for (const asset of result.assets) {
|
||||
// Find the newly created image block by localUri
|
||||
for (const asset of assets) {
|
||||
uploadImageByLocalUri(asset);
|
||||
}
|
||||
}
|
||||
}, [activeBlockId, focusBlock]);
|
||||
|
||||
const uploadImageByLocalUri = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? {
|
||||
...b,
|
||||
remoteUrl: uploadResult.url,
|
||||
uploading: false,
|
||||
width: uploadResult.width ?? b.width,
|
||||
height: uploadResult.height ?? b.height,
|
||||
}
|
||||
: b
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Upload failed
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
}
|
||||
} catch {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[activeBlockId, focusBlock, uploadImageByLocalUri],
|
||||
);
|
||||
|
||||
const insertImage = useCallback(async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: 0,
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
insertImagesFromAssets(result.assets);
|
||||
}
|
||||
}, [insertImagesFromAssets]);
|
||||
|
||||
const insertCameraPhoto = useCallback(async () => {
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相机权限来拍照');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets[0]) {
|
||||
insertImagesFromAssets([result.assets[0]]);
|
||||
}
|
||||
}, [insertImagesFromAssets]);
|
||||
|
||||
const insertTextAtCursor = useCallback((text: string) => {
|
||||
const activeId = activeBlockId;
|
||||
if (!activeId) return;
|
||||
@@ -278,6 +303,7 @@ export function useBlockEditor() {
|
||||
updateTextBlock,
|
||||
removeImageBlock,
|
||||
insertImage,
|
||||
insertCameraPhoto,
|
||||
insertTextAtCursor,
|
||||
focusBlock,
|
||||
getSegments,
|
||||
|
||||
Reference in New Issue
Block a user