feat(business): implement block-based content editing and rich text rendering
Introduce a new `BlockEditor` component and upgrade the post/message rendering system to support rich text segments (images, @mentions, votes, and post references). - Implement `BlockEditor` for long-form post creation with image embedding support. - Upgrade `PostContentRenderer` and `SegmentRenderer` to handle complex segment types including inline images and block elements. - Refactor `PostCard` to use segment-based content and image signatures for optimized memoization. - Centralize segment partitioning logic in `segmentUtils.ts` to ensure consistent rendering across chat and post modules. - Update `PostRepository` and `Post` entity to support the new `segments` data structure.
This commit is contained in:
289
src/components/business/BlockEditor/useBlockEditor.ts
Normal file
289
src/components/business/BlockEditor/useBlockEditor.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { TextInput, Alert } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
import { uploadService } from '@/services/upload';
|
||||
import type { EditorBlock, TextBlock, ImageBlock } from './blockEditorTypes';
|
||||
import {
|
||||
generateBlockId,
|
||||
createInitialTextBlock,
|
||||
remapMentionsBefore,
|
||||
remapMentionsAfter,
|
||||
mergeMentions,
|
||||
} from './blockEditorTypes';
|
||||
import { blocksToSegments, blocksToContent } from './blocksToSegments';
|
||||
|
||||
interface MentionData {
|
||||
endIndex: number;
|
||||
userId: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export function useBlockEditor() {
|
||||
const [blocks, setBlocks] = useState<EditorBlock[]>([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());
|
||||
|
||||
const updateTextBlock = useCallback(
|
||||
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.id === blockId && b.type === 'text'
|
||||
? { ...b, text, activeMentions: mentions }
|
||||
: b
|
||||
)
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const focusBlock = useCallback((blockId: string) => {
|
||||
requestAnimationFrame(() => {
|
||||
const ref = inputRefs.current.get(blockId);
|
||||
ref?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeImageBlock = useCallback((blockId: string) => {
|
||||
setBlocks(prev => {
|
||||
const index = prev.findIndex(b => b.id === blockId);
|
||||
if (index < 0) return prev;
|
||||
|
||||
const updated = prev.filter(b => b.id !== blockId);
|
||||
|
||||
// Merge adjacent text blocks if both neighbors are text
|
||||
const before = updated[index - 1];
|
||||
const after = updated[index]; // was index + 1 before removal
|
||||
|
||||
if (
|
||||
before && after &&
|
||||
before.type === 'text' && after.type === 'text'
|
||||
) {
|
||||
const mergedMentions = mergeMentions(
|
||||
before.activeMentions,
|
||||
before.text.length,
|
||||
after.activeMentions,
|
||||
);
|
||||
const mergedBlock: TextBlock = {
|
||||
...before,
|
||||
text: before.text + after.text,
|
||||
activeMentions: mergedMentions,
|
||||
};
|
||||
updated[index - 1] = mergedBlock;
|
||||
updated.splice(index, 1);
|
||||
setActiveBlockId(mergedBlock.id);
|
||||
requestAnimationFrame(() => focusBlock(mergedBlock.id));
|
||||
}
|
||||
|
||||
// Ensure at least one text block exists
|
||||
if (updated.length === 0) {
|
||||
const newBlock = createInitialTextBlock();
|
||||
updated.push(newBlock);
|
||||
setActiveBlockId(newBlock.id);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [focusBlock]);
|
||||
|
||||
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) {
|
||||
setBlocks(prev => {
|
||||
let updated = [...prev];
|
||||
let currentActiveId = activeBlockId;
|
||||
let insertAfterIndex = updated.findIndex(b => b.id === currentActiveId);
|
||||
|
||||
if (insertAfterIndex < 0) {
|
||||
insertAfterIndex = updated.length - 1;
|
||||
}
|
||||
|
||||
for (const asset of result.assets) {
|
||||
const activeBlock = updated[insertAfterIndex];
|
||||
const cursorPos = activeBlock && activeBlock.type === 'text'
|
||||
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
||||
: 0;
|
||||
|
||||
// Create image block
|
||||
const imageBlockId = generateBlockId();
|
||||
const imageBlock: ImageBlock = {
|
||||
id: imageBlockId,
|
||||
type: 'image',
|
||||
localUri: asset.uri,
|
||||
uploading: true,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
|
||||
if (activeBlock && activeBlock.type === 'text') {
|
||||
const textBefore = activeBlock.text.slice(0, cursorPos);
|
||||
const textAfter = activeBlock.text.slice(cursorPos);
|
||||
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
|
||||
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
|
||||
|
||||
// Create new text block for text after cursor
|
||||
const newTextBlockId = generateBlockId();
|
||||
const newTextBlock: TextBlock = {
|
||||
id: newTextBlockId,
|
||||
type: 'text',
|
||||
text: textAfter,
|
||||
activeMentions: mentionsAfter,
|
||||
};
|
||||
|
||||
// Update current block to text before cursor
|
||||
updated[insertAfterIndex] = {
|
||||
...activeBlock,
|
||||
text: textBefore,
|
||||
activeMentions: mentionsBefore,
|
||||
};
|
||||
|
||||
// Insert image block and new text block
|
||||
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
|
||||
|
||||
// Update index for next image insertion
|
||||
insertAfterIndex = insertAfterIndex + 2;
|
||||
|
||||
// Focus the new text block after the image
|
||||
setActiveBlockId(newTextBlockId);
|
||||
requestAnimationFrame(() => focusBlock(newTextBlockId));
|
||||
} else {
|
||||
// Insert after the current block (which is an image)
|
||||
updated.splice(insertAfterIndex + 1, 0, imageBlock);
|
||||
insertAfterIndex = insertAfterIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Start async uploads
|
||||
for (const asset of result.assets) {
|
||||
// Find the newly created image block by localUri
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const insertTextAtCursor = useCallback((text: string) => {
|
||||
const activeId = activeBlockId;
|
||||
if (!activeId) return;
|
||||
|
||||
setBlocks(prev =>
|
||||
prev.map(b => {
|
||||
if (b.id !== activeId || b.type !== 'text') return b;
|
||||
|
||||
const cursorPos = blockCursors.current.get(b.id)?.start ?? b.text.length;
|
||||
const newText = b.text.slice(0, cursorPos) + text + b.text.slice(cursorPos);
|
||||
|
||||
// Shift mentions after cursor
|
||||
const newMentions = new Map<number, MentionData>();
|
||||
b.activeMentions.forEach((mention, start) => {
|
||||
if (start >= cursorPos) {
|
||||
newMentions.set(start + text.length, {
|
||||
...mention,
|
||||
endIndex: mention.endIndex + text.length,
|
||||
});
|
||||
} else {
|
||||
newMentions.set(start, mention);
|
||||
}
|
||||
});
|
||||
|
||||
const newPosition = cursorPos + text.length;
|
||||
blockCursors.current.set(b.id, { start: newPosition, end: newPosition });
|
||||
|
||||
return { ...b, text: newText, activeMentions: newMentions };
|
||||
})
|
||||
);
|
||||
}, [activeBlockId]);
|
||||
|
||||
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
|
||||
const getContent = useCallback(() => blocksToContent(blocks), [blocks]);
|
||||
const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]);
|
||||
|
||||
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
|
||||
if (ref) {
|
||||
inputRefs.current.set(blockId, ref);
|
||||
} else {
|
||||
inputRefs.current.delete(blockId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateCursor = useCallback((blockId: string, selection: { start: number; end: number }) => {
|
||||
blockCursors.current.set(blockId, selection);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
activeBlockId,
|
||||
setActiveBlockId,
|
||||
updateTextBlock,
|
||||
removeImageBlock,
|
||||
insertImage,
|
||||
insertTextAtCursor,
|
||||
focusBlock,
|
||||
getSegments,
|
||||
getContent,
|
||||
hasUploadingImages,
|
||||
registerInputRef,
|
||||
updateCursor,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user