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:
@@ -41,6 +41,7 @@ import { ApiError } from '@/services/core';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import VoteEditor from '../../components/business/VoteEditor';
|
||||
import PostMentionInput from '../../components/business/PostMentionInput';
|
||||
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { MessageSegment } from '../../types';
|
||||
@@ -135,12 +136,16 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [loadingPost, setLoadingPost] = useState(false);
|
||||
|
||||
// 长文模式:图片作为 image segment 嵌入文本
|
||||
const [isLongPostMode, setIsLongPostMode] = useState(false);
|
||||
|
||||
// 投票相关状态
|
||||
const [isVotePost, setIsVotePost] = useState(false);
|
||||
const [voteOptions, setVoteOptions] = useState<string[]>(['', '']); // 默认2个选项
|
||||
|
||||
// 内容输入框引用
|
||||
const contentInputRef = React.useRef<TextInput>(null);
|
||||
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
|
||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||
|
||||
// 动画值
|
||||
@@ -218,7 +223,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
loadPostForEdit();
|
||||
}, [isEditMode, editPostID, router]);
|
||||
|
||||
// 选择图片
|
||||
// 选择图片(普通模式)
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
@@ -308,7 +313,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 选择频道(单选,可再次点击取消)
|
||||
// 选择频道(单选,可再次点击取消)
|
||||
const handleSelectChannel = useCallback((channelId: string) => {
|
||||
setSelectedChannelId(prev => (prev === channelId ? null : channelId));
|
||||
setShowChannelPicker(false);
|
||||
@@ -316,6 +321,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
// 插入表情
|
||||
const handleInsertEmoji = (emoji: string) => {
|
||||
if (isLongPostMode) {
|
||||
blockEditorRef.current?.insertTextAtCursor(emoji);
|
||||
return;
|
||||
}
|
||||
const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end);
|
||||
setContent(newContent);
|
||||
const newPosition = selection.start + emoji.length;
|
||||
@@ -381,24 +390,34 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
const contentToCheck = isLongPostMode
|
||||
? (blockEditorRef.current?.getContent() || content)
|
||||
: content;
|
||||
|
||||
if (!contentToCheck.trim()) {
|
||||
Alert.alert('错误', '请输入帖子内容');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有图片正在上传
|
||||
const uploadingImages = images.filter(img => img.uploading);
|
||||
if (uploadingImages.length > 0) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
if (isLongPostMode) {
|
||||
if (blockEditorRef.current?.hasUploadingImages()) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const uploadingImages = images.filter(img => img.uploading);
|
||||
if (uploadingImages.length > 0) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPosting(true);
|
||||
|
||||
try {
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
|
||||
if (isVotePost) {
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
// 验证投票选项
|
||||
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
|
||||
if (validOptions.length < 2) {
|
||||
@@ -406,7 +425,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
setPosting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 创建投票帖子
|
||||
await voteService.createVotePost({
|
||||
title: title.trim(),
|
||||
@@ -422,7 +441,35 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
duration: 2600,
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
} else if (isLongPostMode) {
|
||||
// 长文模式:从 BlockEditor 获取 segments
|
||||
const editorSegments = blockEditorRef.current?.getSegments() || [];
|
||||
const editorContent = blockEditorRef.current?.getContent() || '';
|
||||
const finalSegments = editorSegments.length > 0 ? editorSegments : undefined;
|
||||
if (isEditMode && editPostID) {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
title: title.trim(),
|
||||
content: editorContent.trim(),
|
||||
segments: finalSegments,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error('更新帖子失败');
|
||||
}
|
||||
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
|
||||
router.replace(hrefs.hrefHome());
|
||||
} else {
|
||||
await postService.createPost({
|
||||
title: title.trim(),
|
||||
content: editorContent.trim(),
|
||||
segments: finalSegments,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
});
|
||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
} else {
|
||||
// 普通模式
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
if (isEditMode && editPostID) {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
title: title.trim(),
|
||||
@@ -433,12 +480,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
if (!updated) {
|
||||
throw new Error('更新帖子失败');
|
||||
}
|
||||
showPrompt({
|
||||
type: 'success',
|
||||
title: '修改成功',
|
||||
message: '帖子内容已更新',
|
||||
duration: 2200,
|
||||
});
|
||||
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
|
||||
router.replace(hrefs.hrefHome());
|
||||
} else {
|
||||
await postService.createPost({
|
||||
@@ -448,12 +490,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
});
|
||||
showPrompt({
|
||||
type: 'info',
|
||||
title: '审核中',
|
||||
message: '帖子已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}
|
||||
@@ -619,23 +656,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
|
||||
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
|
||||
<PostMentionInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
onSegmentsChange={setSegments}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
minHeight={contentInputMinHeight}
|
||||
autoExpand
|
||||
style={[
|
||||
styles.contentInput,
|
||||
isWideScreen && styles.contentInputWide,
|
||||
]}
|
||||
/>
|
||||
{isLongPostMode ? (
|
||||
<BlockEditor
|
||||
ref={blockEditorRef}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
textStyle={[
|
||||
styles.contentInput,
|
||||
isWideScreen && styles.contentInputWide,
|
||||
]}
|
||||
onContentChange={setContent}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
|
||||
<PostMentionInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
onSegmentsChange={setSegments}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
minHeight={contentInputMinHeight}
|
||||
autoExpand
|
||||
style={[
|
||||
styles.contentInput,
|
||||
isWideScreen && styles.contentInputWide,
|
||||
]}
|
||||
/>
|
||||
{renderImageGrid()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 作为正文容器的子模块,追加在文本后面 */}
|
||||
{renderImageGrid()}
|
||||
{renderVoteEditor()}
|
||||
</View>
|
||||
);
|
||||
@@ -688,7 +739,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
<View style={styles.toolbarLeft}>
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handlePickImage}
|
||||
onPress={isLongPostMode ? () => blockEditorRef.current?.insertImage() : handlePickImage}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
@@ -700,19 +751,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handleTakePhoto}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={24}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{!isLongPostMode && (
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handleTakePhoto}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={24}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
@@ -742,6 +795,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 长文模式开关 */}
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={() => setIsLongPostMode(prev => !prev)}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name={isLongPostMode ? "text-box" : "text-box-outline"}
|
||||
size={24}
|
||||
color={isLongPostMode ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1125,11 +1125,12 @@ export const PostDetailScreen: React.FC = () => {
|
||||
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
|
||||
}
|
||||
]}
|
||||
onImagePress={(url) => handleImagePress([{ uri: url }], 0)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 图片 - 详情页显示所有图片,不限制数量 */}
|
||||
{postImages.length > 0 && (
|
||||
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
||||
{!post.segments?.some(s => s.type === 'image') && postImages.length > 0 && (
|
||||
<ImageGrid
|
||||
images={postImages}
|
||||
mode="auto"
|
||||
|
||||
@@ -39,43 +39,7 @@ const IMAGE_MAX_WIDTH = 200;
|
||||
const IMAGE_MAX_HEIGHT = 260;
|
||||
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
|
||||
|
||||
/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */
|
||||
type ContentChunk =
|
||||
| { kind: 'inline'; parts: MessageSegment[] }
|
||||
| { kind: 'images'; parts: MessageSegment[] }
|
||||
| { kind: 'block'; segment: MessageSegment };
|
||||
|
||||
function partitionMessageSegments(segments: MessageSegment[]): ContentChunk[] {
|
||||
const out: ContentChunk[] = [];
|
||||
let inlineBuf: MessageSegment[] = [];
|
||||
const flushInline = () => {
|
||||
if (inlineBuf.length) {
|
||||
out.push({ kind: 'inline', parts: [...inlineBuf] });
|
||||
inlineBuf = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const s of segments) {
|
||||
if (s.type === 'image') {
|
||||
flushInline();
|
||||
const last = out[out.length - 1];
|
||||
if (last?.kind === 'images') {
|
||||
last.parts.push(s);
|
||||
} else {
|
||||
out.push({ kind: 'images', parts: [s] });
|
||||
}
|
||||
} else if (s.type === 'video' || s.type === 'file' || s.type === 'link' || s.type === 'voice') {
|
||||
flushInline();
|
||||
out.push({ kind: 'block', segment: s });
|
||||
} else if (s.type === 'text' || s.type === 'at' || s.type === 'face') {
|
||||
inlineBuf.push(s);
|
||||
} else {
|
||||
inlineBuf.push(s);
|
||||
}
|
||||
}
|
||||
flushInline();
|
||||
return out;
|
||||
}
|
||||
import { partitionSegments, type ContentChunk } from '../../../../types/dto';
|
||||
|
||||
// Segment 渲染器 Props
|
||||
export interface SegmentRendererProps {
|
||||
@@ -741,7 +705,7 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
|
||||
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
|
||||
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
|
||||
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
|
||||
const chunks = useMemo(() => partitionSegments(otherSegments), [otherSegments]);
|
||||
|
||||
const renderProps = useMemo(() => ({
|
||||
isMe,
|
||||
|
||||
@@ -296,11 +296,11 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
senderName: {
|
||||
fontSize: 14,
|
||||
color: textPrimary,
|
||||
fontSize: 12,
|
||||
color: textSecondary,
|
||||
marginBottom: 4,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600',
|
||||
fontWeight: '500',
|
||||
},
|
||||
mySenderName: {
|
||||
marginLeft: 0,
|
||||
|
||||
Reference in New Issue
Block a user