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:
118
src/components/business/BlockEditor/BlockEditor.tsx
Normal file
118
src/components/business/BlockEditor/BlockEditor.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useImperativeHandle, useCallback } from 'react';
|
||||
import { View, ScrollView, StyleSheet } from 'react-native';
|
||||
|
||||
import { useAppColors, spacing } from '../../../theme';
|
||||
import { MessageSegment } from '../../../types';
|
||||
import TextBlockInput from './TextBlockInput';
|
||||
import ImageBlockView from './ImageBlockView';
|
||||
import { useBlockEditor } from './useBlockEditor';
|
||||
|
||||
export interface BlockEditorHandle {
|
||||
insertImage: () => Promise<void>;
|
||||
insertTextAtCursor: (text: string) => void;
|
||||
getSegments: () => MessageSegment[];
|
||||
getContent: () => string;
|
||||
hasUploadingImages: () => boolean;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface BlockEditorProps {
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
onContentChange?: (content: string) => void;
|
||||
}
|
||||
|
||||
const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
||||
({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange }, ref) => {
|
||||
const colors = useAppColors();
|
||||
const editor = useBlockEditor();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertImage: editor.insertImage,
|
||||
insertTextAtCursor: editor.insertTextAtCursor,
|
||||
getSegments: editor.getSegments,
|
||||
getContent: editor.getContent,
|
||||
hasUploadingImages: editor.hasUploadingImages,
|
||||
focus: () => {
|
||||
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
||||
if (firstTextBlock) {
|
||||
editor.focusBlock(firstTextBlock.id);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(blockId: string, text: string, mentions: any) => {
|
||||
editor.updateTextBlock(blockId, text, mentions);
|
||||
onContentChange?.(editor.getContent());
|
||||
},
|
||||
[editor.updateTextBlock, editor.getContent, onContentChange],
|
||||
);
|
||||
|
||||
const handleRemoveImage = useCallback(
|
||||
(blockId: string) => {
|
||||
editor.removeImageBlock(blockId);
|
||||
onContentChange?.(editor.getContent());
|
||||
},
|
||||
[editor.removeImageBlock, editor.getContent, onContentChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{editor.blocks.map(block => {
|
||||
if (block.type === 'text') {
|
||||
return (
|
||||
<TextBlockInput
|
||||
key={block.id}
|
||||
block={block}
|
||||
isActive={editor.activeBlockId === block.id}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onTextChange={handleTextChange}
|
||||
onFocusBlock={editor.setActiveBlockId}
|
||||
onSelectMention={() => {}}
|
||||
inputRef={(ref) => editor.registerInputRef(block.id, ref)}
|
||||
style={textStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageBlockView
|
||||
key={block.id}
|
||||
block={block}
|
||||
onRemove={handleRemoveImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BlockEditor.displayName = 'BlockEditor';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default BlockEditor;
|
||||
92
src/components/business/BlockEditor/ImageBlockView.tsx
Normal file
92
src/components/business/BlockEditor/ImageBlockView.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { useAppColors, spacing, borderRadius } from '../../../theme';
|
||||
import type { ImageBlock } from './blockEditorTypes';
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
||||
|
||||
interface ImageBlockViewProps {
|
||||
block: ImageBlock;
|
||||
onRemove: (blockId: string) => void;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style }) => {
|
||||
const colors = useAppColors();
|
||||
|
||||
const aspectRatio = block.width && block.height
|
||||
? block.width / block.height
|
||||
: 4 / 3;
|
||||
const displayWidth = IMAGE_MAX_WIDTH;
|
||||
const displayHeight = displayWidth / aspectRatio;
|
||||
|
||||
const imageSource = block.remoteUrl
|
||||
? { uri: block.remoteUrl }
|
||||
: { uri: block.localUri };
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ExpoImage
|
||||
source={imageSource}
|
||||
style={{
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
borderRadius: borderRadius.md,
|
||||
}}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
/>
|
||||
{block.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={() => onRemove(block.id)}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeButtonInner}>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.text.inverse} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 4,
|
||||
marginBottom: 0,
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8 + (SCREEN_WIDTH - IMAGE_MAX_WIDTH) / 2,
|
||||
zIndex: 10,
|
||||
},
|
||||
removeButtonInner: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default ImageBlockView;
|
||||
276
src/components/business/BlockEditor/TextBlockInput.tsx
Normal file
276
src/components/business/BlockEditor/TextBlockInput.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import Text from '../../common/Text';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { useAppColors, spacing, fontSizes, borderRadius } from '../../../theme';
|
||||
import { useAuthStore } from '../../../stores';
|
||||
import { authService } from '../../../services/auth';
|
||||
import type { TextBlock } from './blockEditorTypes';
|
||||
|
||||
interface MentionUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface TextBlockInputProps {
|
||||
block: TextBlock;
|
||||
isActive: boolean;
|
||||
placeholder: string;
|
||||
maxLength: number;
|
||||
onTextChange: (blockId: string, text: string, mentions: Map<number, { endIndex: number; userId: string; nickname: string }>) => void;
|
||||
onFocusBlock: (blockId: string) => void;
|
||||
onSelectMention: (blockId: string, userId: string, nickname: string, mentionStartIndex: number) => void;
|
||||
inputRef: (ref: TextInput | null) => void;
|
||||
style?: any;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
const TextBlockInput: React.FC<TextBlockInputProps> = ({
|
||||
block,
|
||||
isActive,
|
||||
placeholder,
|
||||
maxLength,
|
||||
onTextChange,
|
||||
onFocusBlock,
|
||||
onSelectMention,
|
||||
inputRef,
|
||||
style,
|
||||
minHeight,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const currentUser = useAuthStore(s => s.currentUser);
|
||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [suggestionMode, setSuggestionMode] = useState<'none' | 'mention'>('none');
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded && currentUser?.id) {
|
||||
loadFollowing();
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [currentUser?.id, loaded]);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
if (!currentUser?.id) return;
|
||||
try {
|
||||
const list = await authService.getFollowingList(currentUser.id, 1, 200);
|
||||
setFollowingUsers(
|
||||
list.map(u => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname || u.username || '',
|
||||
avatar: u.avatar || null,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = followingUsers.filter(u =>
|
||||
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(newText: string) => {
|
||||
const cursorPos = newText.length;
|
||||
|
||||
// Check for @ trigger
|
||||
let atStart = -1;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (newText[i] === '@') {
|
||||
atStart = i;
|
||||
break;
|
||||
}
|
||||
if (newText[i] === ' ' || newText[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atStart >= 0) {
|
||||
const query = newText.slice(atStart + 1, cursorPos);
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
setMentionQuery(query);
|
||||
setMentionStartIndex(atStart);
|
||||
setSuggestionMode('mention');
|
||||
} else {
|
||||
setSuggestionMode('none');
|
||||
}
|
||||
} else {
|
||||
setSuggestionMode('none');
|
||||
}
|
||||
|
||||
// Update mentions when text changes: remap existing mentions
|
||||
const newMentions = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||
// Simple approach: only keep mentions tracked by this block's own activeMentions
|
||||
// The parent handles the canonical mentions state
|
||||
block.activeMentions.forEach((mention, start) => {
|
||||
if (start < newText.length) {
|
||||
const mentionText = `@${mention.nickname} `;
|
||||
const expectedEnd = start + mentionText.length;
|
||||
// Check if the mention text is still intact
|
||||
if (newText.slice(start, expectedEnd) === mentionText) {
|
||||
newMentions.set(start, { ...mention, endIndex: expectedEnd });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onTextChange(block.id, newText, newMentions);
|
||||
},
|
||||
[block.id, block.activeMentions, onTextChange],
|
||||
);
|
||||
|
||||
const handleSelectMention = useCallback(
|
||||
(mentionUser: MentionUser) => {
|
||||
if (mentionStartIndex < 0) return;
|
||||
|
||||
const before = block.text.slice(0, mentionStartIndex);
|
||||
const mentionText = `@${mentionUser.nickname} `;
|
||||
const newText = before + mentionText;
|
||||
|
||||
const newMentions = new Map(block.activeMentions);
|
||||
newMentions.set(mentionStartIndex, {
|
||||
endIndex: before.length + mentionText.length,
|
||||
userId: mentionUser.id,
|
||||
nickname: mentionUser.nickname,
|
||||
});
|
||||
|
||||
// Also remove any mention that was partially covered
|
||||
const toRemove: number[] = [];
|
||||
newMentions.forEach((m, start) => {
|
||||
if (start !== mentionStartIndex && start >= mentionStartIndex && start < before.length + mentionText.length) {
|
||||
toRemove.push(start);
|
||||
}
|
||||
});
|
||||
toRemove.forEach(k => newMentions.delete(k));
|
||||
|
||||
onTextChange(block.id, newText, newMentions);
|
||||
setSuggestionMode('none');
|
||||
setMentionQuery('');
|
||||
setMentionStartIndex(-1);
|
||||
|
||||
onSelectMention(block.id, mentionUser.id, mentionUser.nickname, mentionStartIndex);
|
||||
},
|
||||
[block.id, block.text, mentionStartIndex, onTextChange, onSelectMention],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={block.text}
|
||||
onChangeText={handleChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={maxLength}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
scrollEnabled={false}
|
||||
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||
onFocus={() => onFocusBlock(block.id)}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.text.primary,
|
||||
minHeight: minHeight,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
|
||||
{suggestionMode === 'mention' && isActive && filteredUsers.length > 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
style={styles.mentionList}
|
||||
>
|
||||
{filteredUsers.map(item => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
||||
onPress={() => handleSelectMention(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar source={item.avatar} size={36} name={item.nickname} />
|
||||
<View style={styles.mentionInfo}>
|
||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
||||
点击提及
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{suggestionMode === 'mention' && isActive && filteredUsers.length === 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
||||
没有找到匹配的关注用户
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
input: {
|
||||
fontSize: fontSizes.md,
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
textAlignVertical: 'top' as const,
|
||||
},
|
||||
mentionPanel: {
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
maxHeight: 200,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
mentionList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
mentionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
mentionInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mentionName: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
mentionHint: {
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: fontSizes.sm,
|
||||
padding: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default TextBlockInput;
|
||||
103
src/components/business/BlockEditor/blockEditorTypes.ts
Normal file
103
src/components/business/BlockEditor/blockEditorTypes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { MessageSegment, AtSegmentData } from '../../../types';
|
||||
|
||||
export interface TextBlock {
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>;
|
||||
}
|
||||
|
||||
export interface ImageBlock {
|
||||
id: string;
|
||||
type: 'image';
|
||||
localUri: string;
|
||||
remoteUrl?: string;
|
||||
uploading: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type EditorBlock = TextBlock | ImageBlock;
|
||||
|
||||
export function generateBlockId(): string {
|
||||
return `blk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function createInitialTextBlock(): TextBlock {
|
||||
return { id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() };
|
||||
}
|
||||
|
||||
export function remapMentionsBefore(
|
||||
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||
cursorPos: number,
|
||||
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
||||
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||
for (const [start, mention] of mentions) {
|
||||
if (mention.endIndex <= cursorPos) {
|
||||
result.set(start, { ...mention });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function remapMentionsAfter(
|
||||
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||
cursorPos: number,
|
||||
textLength: number,
|
||||
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
||||
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||
for (const [start, mention] of mentions) {
|
||||
if (start >= cursorPos) {
|
||||
result.set(start - cursorPos, {
|
||||
...mention,
|
||||
endIndex: mention.endIndex - cursorPos,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeMentions(
|
||||
m1: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||
m1TextLength: number,
|
||||
m2: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
||||
const result = new Map(m1);
|
||||
for (const [start, mention] of m2) {
|
||||
result.set(start + m1TextLength, {
|
||||
...mention,
|
||||
endIndex: mention.endIndex + m1TextLength,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function blockTextToSegments(
|
||||
text: string,
|
||||
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||
): MessageSegment[] {
|
||||
if (activeMentions.size === 0) {
|
||||
return text.trim() ? [{ type: 'text', data: { text } }] : [];
|
||||
}
|
||||
|
||||
const segments: MessageSegment[] = [];
|
||||
const sorted = Array.from(activeMentions.entries()).sort((a, b) => a[0] - b[0]);
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const [startIdx, mention] of sorted) {
|
||||
if (startIdx > lastEnd) {
|
||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
|
||||
}
|
||||
segments.push({
|
||||
type: 'at',
|
||||
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
|
||||
});
|
||||
lastEnd = mention.endIndex;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
57
src/components/business/BlockEditor/blocksToSegments.ts
Normal file
57
src/components/business/BlockEditor/blocksToSegments.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { MessageSegment } from '../../../types';
|
||||
import { EditorBlock, blockTextToSegments } from './blockEditorTypes';
|
||||
|
||||
export function blocksToSegments(blocks: EditorBlock[]): MessageSegment[] {
|
||||
const segments: MessageSegment[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
const textSegments = blockTextToSegments(block.text, block.activeMentions);
|
||||
segments.push(...textSegments);
|
||||
} else if (block.type === 'image') {
|
||||
if (block.remoteUrl) {
|
||||
segments.push({
|
||||
type: 'image',
|
||||
data: {
|
||||
url: block.remoteUrl,
|
||||
width: block.width,
|
||||
height: block.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeAdjacentTextSegments(segments);
|
||||
}
|
||||
|
||||
function mergeAdjacentTextSegments(segments: MessageSegment[]): MessageSegment[] {
|
||||
if (segments.length === 0) return segments;
|
||||
|
||||
const merged: MessageSegment[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.type === 'text' &&
|
||||
seg.type === 'text'
|
||||
) {
|
||||
const lastText = last.data?.text || last.data?.content || '';
|
||||
const curText = seg.data?.text || seg.data?.content || '';
|
||||
last.data = { text: lastText + curText };
|
||||
} else {
|
||||
merged.push({ ...seg });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function blocksToContent(blocks: EditorBlock[]): string {
|
||||
return blocks
|
||||
.filter((b): b is typeof b & { type: 'text' } => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
2
src/components/business/BlockEditor/index.ts
Normal file
2
src/components/business/BlockEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './BlockEditor';
|
||||
export type { BlockEditorHandle } from './BlockEditor';
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -16,13 +16,12 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PostCardProps, PostCardAction } from './types';
|
||||
import { ImageGridItem, SmartImage } from '../../common';
|
||||
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
|
||||
import Text from '../../common/Text';
|
||||
import HighlightText from '../../common/HighlightText';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
import PostImages from './components/PostImages';
|
||||
import PostContentRenderer from '../PostContentRenderer';
|
||||
import { useResponsive } from '../../../hooks';
|
||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||
@@ -55,6 +54,13 @@ function imagesSignature(
|
||||
return images.map((i) => i.id).join('\u001f');
|
||||
}
|
||||
|
||||
function segmentSignature(
|
||||
segments: PostCardProps['post']['segments'] | undefined
|
||||
): string {
|
||||
if (!segments?.length) return '';
|
||||
return segments.map(s => `${s.type}:${s.type === 'image' ? (s.data as any)?.url || '' : ''}`).join('\u001f');
|
||||
}
|
||||
|
||||
function authorSignature(author: PostCardProps['post']['author']): string {
|
||||
if (!author) return '';
|
||||
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
||||
@@ -110,6 +116,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
||||
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
||||
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||
if (segmentSignature(a.segments) !== segmentSignature(b.segments)) return false;
|
||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||
|
||||
@@ -426,6 +433,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
const rawContent = post.content ?? '';
|
||||
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
||||
|
||||
const displayContent = useMemo(() => {
|
||||
if (!post.segments?.length) return rawContent;
|
||||
const segs = post.segments;
|
||||
let result = '';
|
||||
for (const s of segs) {
|
||||
if (s.type === 'image') {
|
||||
result += '[图片]';
|
||||
} else if (s.type === 'text') {
|
||||
result += (s.data as any)?.text || (s.data as any)?.content || '';
|
||||
} else if (s.type === 'at') {
|
||||
result += `@${(s.data as any)?.nickname || '某人'}`;
|
||||
}
|
||||
}
|
||||
return result || rawContent;
|
||||
}, [post.segments, rawContent]);
|
||||
|
||||
const handleCardPress = () => emit({ type: 'press' });
|
||||
const handleUserPress = () => emit({ type: 'userPress' });
|
||||
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
||||
@@ -455,17 +478,29 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const images: ImageGridItem[] = Array.isArray(post.images)
|
||||
? post.images.map((img) => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))
|
||||
: [];
|
||||
const segmentImages: ImageGridItem[] = (post.segments || [])
|
||||
.filter((s: any) => s.type === 'image' && s.data?.url)
|
||||
.map((s: any, i: number) => ({
|
||||
id: `seg-${i}`,
|
||||
url: s.data.url,
|
||||
width: s.data.width,
|
||||
height: s.data.height,
|
||||
}));
|
||||
|
||||
const images: ImageGridItem[] = [
|
||||
...Array.isArray(post.images)
|
||||
? post.images.map((img) => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))
|
||||
: [],
|
||||
...segmentImages,
|
||||
];
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
||||
@@ -538,10 +573,10 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
return `${value.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
||||
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
|
||||
const content = useMemo(
|
||||
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
||||
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
|
||||
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
);
|
||||
|
||||
const renderImages = () => {
|
||||
@@ -563,10 +598,24 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PostImages
|
||||
images={post.images || []}
|
||||
displayMode="list"
|
||||
<ImageGrid
|
||||
images={images.map(img => ({
|
||||
id: img.id,
|
||||
url: img.url || '',
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))}
|
||||
maxDisplayCount={isWideScreen ? 12 : 9}
|
||||
mode="auto"
|
||||
gap={isDesktop ? 4 : 2}
|
||||
borderRadius={isDesktop ? borderRadius.xl : borderRadius.md}
|
||||
showMoreOverlay={true}
|
||||
onImagePress={handleImagePress}
|
||||
displayMode="list"
|
||||
usePreview={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -704,24 +753,12 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!isCompact && !!content && (
|
||||
<>
|
||||
{post.segments && post.segments.length > 0 ? (
|
||||
<PostContentRenderer
|
||||
content={content}
|
||||
segments={post.segments}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
style={styles.contentRenderer}
|
||||
textStyle={styles.content}
|
||||
highlightKeyword={highlightKeyword}
|
||||
highlightStyle={styles.highlight}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
||||
@@ -730,6 +767,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
||||
{renderImages()}
|
||||
{renderTopComment()}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export interface PostContentProps {
|
||||
* PostImages 子组件 Props
|
||||
*/
|
||||
export interface PostImagesProps {
|
||||
images: PostImageDTO[];
|
||||
images: (PostImageDTO | { id?: string; url: string; thumbnail_url?: string; preview_url?: string; preview_url_large?: string; width?: number; height?: number })[];
|
||||
displayMode: 'list' | 'grid';
|
||||
onImagePress: (images: ImageGridItem[], index: number) => void;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* PostContentRenderer - 帖子内容渲染器
|
||||
* 根据 segments 渲染富文本内容(@提及、投票等)
|
||||
* 根据 segments 渲染富文本内容(@提及、投票、内嵌图片等)
|
||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp } from 'react-native';
|
||||
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp, Dimensions } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
|
||||
import Text from '../common/Text';
|
||||
import HighlightText from '../common/HighlightText';
|
||||
import { useAppColors } from '../../theme';
|
||||
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
|
||||
import {
|
||||
MessageSegment,
|
||||
AtSegmentData,
|
||||
VoteSegmentData,
|
||||
PostRefSegmentData,
|
||||
ImageSegmentData,
|
||||
partitionSegments,
|
||||
} from '../../types';
|
||||
import PostRefCard from './PostRefCard';
|
||||
|
||||
interface PostContentRendererProps {
|
||||
@@ -19,24 +27,35 @@ interface PostContentRendererProps {
|
||||
numberOfLines?: number;
|
||||
onMentionPress?: (userId: string) => void;
|
||||
onPostRefPress?: (postId: string) => void;
|
||||
onImagePress?: (url: string) => void;
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
highlightKeyword?: string;
|
||||
highlightStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const INLINE_IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
||||
|
||||
function hasInlineImages(segments?: MessageSegment[]): boolean {
|
||||
if (!segments) return false;
|
||||
return segments.some(s => s.type === 'image');
|
||||
}
|
||||
|
||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
content,
|
||||
segments,
|
||||
numberOfLines,
|
||||
onMentionPress,
|
||||
onPostRefPress,
|
||||
onImagePress,
|
||||
style,
|
||||
textStyle,
|
||||
highlightKeyword,
|
||||
highlightStyle,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const hasImages = hasInlineImages(segments);
|
||||
|
||||
if (!segments || segments.length === 0) {
|
||||
return (
|
||||
@@ -50,115 +69,198 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let keyIndex = 0;
|
||||
const hasComplexContent = hasImages ||
|
||||
segments.some(s => s.type === 'vote' || s.type === 'post_ref');
|
||||
|
||||
for (const segment of segments) {
|
||||
const key = `seg_${keyIndex++}`;
|
||||
if (!hasComplexContent || (numberOfLines && !hasImages)) {
|
||||
const elements: React.ReactNode[] = [];
|
||||
let keyIndex = 0;
|
||||
|
||||
switch (segment.type) {
|
||||
case 'text': {
|
||||
const text = segment.data?.text || segment.data?.content || '';
|
||||
if (text) {
|
||||
for (const segment of segments) {
|
||||
const key = `seg_${keyIndex++}`;
|
||||
switch (segment.type) {
|
||||
case 'text': {
|
||||
const text = segment.data?.text || segment.data?.content || '';
|
||||
if (text) {
|
||||
elements.push(
|
||||
<Text key={key} selectable style={textStyle}>
|
||||
{highlightKeyword ? (
|
||||
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'at': {
|
||||
const atData = segment.data as AtSegmentData;
|
||||
const userId = atData?.user_id;
|
||||
const isAtAll = userId === 'all';
|
||||
const displayName = atData?.nickname || (isAtAll ? '所有人' : '某人');
|
||||
elements.push(
|
||||
<Text key={key} selectable style={textStyle}>
|
||||
{highlightKeyword ? (
|
||||
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
<Text
|
||||
key={key}
|
||||
selectable
|
||||
style={[
|
||||
textStyle,
|
||||
{ color: colors.primary.main, fontWeight: '500' },
|
||||
]}
|
||||
>
|
||||
@{displayName}{' '}
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'image':
|
||||
case 'link':
|
||||
case 'face':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{highlightKeyword && content ? (
|
||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||
) : (
|
||||
content || ''
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfLines) {
|
||||
return (
|
||||
<View style={style}>
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{elements}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[styles.container, style]}>{elements}</View>;
|
||||
}
|
||||
|
||||
const chunks = partitionSegments(segments);
|
||||
|
||||
return (
|
||||
<View style={[styles.chunkContainer, style]}>
|
||||
{chunks.map((chunk, i) => {
|
||||
if (chunk.kind === 'inline') {
|
||||
return (
|
||||
<Text key={`inl-${i}`} selectable style={textStyle}>
|
||||
{chunk.parts.map((segment, j) => {
|
||||
const segKey = `inl-${i}-${j}`;
|
||||
switch (segment.type) {
|
||||
case 'text': {
|
||||
const text = segment.data?.text || segment.data?.content || '';
|
||||
return highlightKeyword ? (
|
||||
<HighlightText key={segKey} text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||
) : (
|
||||
<Text key={segKey}>{text}</Text>
|
||||
);
|
||||
}
|
||||
case 'at': {
|
||||
const atData = segment.data as AtSegmentData;
|
||||
const userId = atData?.user_id;
|
||||
const displayName = atData?.nickname || (userId === 'all' ? '所有人' : '某人');
|
||||
return (
|
||||
<Text
|
||||
key={segKey}
|
||||
style={[textStyle, { color: colors.primary.main, fontWeight: '500' }]}
|
||||
onPress={() => onMentionPress?.(userId)}
|
||||
>
|
||||
@{displayName}{' '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'at': {
|
||||
const atData = segment.data as AtSegmentData;
|
||||
const userId = atData?.user_id;
|
||||
const isAtAll = userId === 'all';
|
||||
const displayName = atData?.nickname || (isAtAll ? '所有人' : '某人');
|
||||
elements.push(
|
||||
<Text
|
||||
key={key}
|
||||
selectable
|
||||
style={[
|
||||
textStyle,
|
||||
{
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
]}
|
||||
>
|
||||
@{displayName}{' '}
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (chunk.kind === 'images') {
|
||||
return (
|
||||
<View key={`imgs-${i}`} style={styles.inlineImagesContainer}>
|
||||
{chunk.parts.map((segment, j) => {
|
||||
const imgData = segment.data as ImageSegmentData;
|
||||
const url = imgData?.url || '';
|
||||
if (!url) return null;
|
||||
|
||||
case 'vote': {
|
||||
const voteData = segment.data as VoteSegmentData;
|
||||
if (voteData?.options?.length) {
|
||||
elements.push(
|
||||
<View key={key} style={[styles.voteBlock, { borderColor: colors.divider }]}>
|
||||
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
|
||||
📊 投票({voteData.options.length} 个选项)
|
||||
</Text>
|
||||
{voteData.options.map((opt, i) => (
|
||||
<Text key={`vote_opt_${i}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
|
||||
{i + 1}. {opt.content}
|
||||
</Text>
|
||||
))}
|
||||
const aspectRatio = imgData.width && imgData.height
|
||||
? imgData.width / imgData.height
|
||||
: 4 / 3;
|
||||
const displayWidth = INLINE_IMAGE_MAX_WIDTH;
|
||||
const displayHeight = displayWidth / aspectRatio;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`img-${i}-${j}`}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => onImagePress?.(url)}
|
||||
style={j < chunk.parts.length - 1 ? styles.imageSpacing : undefined}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: url }}
|
||||
style={{
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'post_ref': {
|
||||
const refData = segment.data as PostRefSegmentData;
|
||||
elements.push(
|
||||
<PostRefCard
|
||||
key={key}
|
||||
data={refData}
|
||||
compact={!!numberOfLines}
|
||||
onPress={onPostRefPress}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'image':
|
||||
case 'link':
|
||||
case 'face':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{highlightKeyword && content ? (
|
||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||
) : (
|
||||
content || ''
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfLines) {
|
||||
return (
|
||||
<View style={style}>
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{elements}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[styles.container, style]}>{elements}</View>;
|
||||
const { segment } = chunk;
|
||||
switch (segment.type) {
|
||||
case 'vote': {
|
||||
const voteData = segment.data as VoteSegmentData;
|
||||
if (!voteData?.options?.length) return null;
|
||||
return (
|
||||
<View key={`blk-${i}`} style={[styles.voteBlock, { borderColor: colors.divider }]}>
|
||||
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
|
||||
投票({voteData.options.length} 个选项)
|
||||
</Text>
|
||||
{voteData.options.map((opt, k) => (
|
||||
<Text key={`vote_opt_${k}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
|
||||
{k + 1}. {opt.content}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
case 'post_ref': {
|
||||
const refData = segment.data as PostRefSegmentData;
|
||||
return (
|
||||
<PostRefCard
|
||||
key={`blk-${i}`}
|
||||
data={refData}
|
||||
compact={false}
|
||||
onPress={onPostRefPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -167,6 +269,15 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
chunkContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
inlineImagesContainer: {
|
||||
marginVertical: 8,
|
||||
},
|
||||
imageSpacing: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
voteBlock: {
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -24,6 +24,8 @@ export { default as VoteEditor } from './VoteEditor';
|
||||
export { default as VotePreview } from './VotePreview';
|
||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||
export { default as PostMentionInput } from './PostMentionInput';
|
||||
export { default as BlockEditor } from './BlockEditor';
|
||||
export type { BlockEditorHandle } from './BlockEditor';
|
||||
export { default as ReportDialog } from './ReportDialog';
|
||||
export { default as ShareSheet } from './ShareSheet';
|
||||
export { TradeCard } from './TradeCard/TradeCard';
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
// ==================== 帖子状态枚举 ====================
|
||||
|
||||
import type { MessageSegment } from '../../types/dto/message';
|
||||
|
||||
export type PostStatus = 'published' | 'draft' | 'deleted';
|
||||
|
||||
// ==================== 值对象 ====================
|
||||
@@ -73,6 +75,8 @@ export interface Post {
|
||||
isFavorited: boolean;
|
||||
/** 是否置顶 (camelCase) */
|
||||
isPinned: boolean;
|
||||
/** 帖子内容段落(图文混排) */
|
||||
segments?: MessageSegment[];
|
||||
/** 帖子状态 */
|
||||
status: PostStatus;
|
||||
/** 所属频道ID */
|
||||
|
||||
@@ -27,6 +27,7 @@ interface PostApiResponse {
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
segments?: any[];
|
||||
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
||||
likes_count: number;
|
||||
comments_count: number;
|
||||
@@ -110,6 +111,7 @@ export class PostRepository implements IPostRepository {
|
||||
} : undefined,
|
||||
title: model.title,
|
||||
content: model.content,
|
||||
segments: response.segments,
|
||||
images: (model.images || []).map(url => ({
|
||||
url,
|
||||
width: undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +1,65 @@
|
||||
/**
|
||||
* Segment text extraction utilities
|
||||
* Segment utilities
|
||||
*/
|
||||
|
||||
import type { MessageSegment, TextSegmentData, AtSegmentData, FileSegmentData, FaceSegmentData, LinkSegmentData, PostRefSegmentData } from './message';
|
||||
import type { UserDTO } from './user';
|
||||
|
||||
// ==================== Chunk Types ====================
|
||||
|
||||
export type ContentChunk =
|
||||
| { kind: 'inline'; parts: MessageSegment[] }
|
||||
| { kind: 'images'; parts: MessageSegment[] }
|
||||
| { kind: 'block'; segment: MessageSegment };
|
||||
|
||||
/**
|
||||
* 将 segments 分成三种 chunk:
|
||||
* - inline: text / at / face 等行内元素
|
||||
* - images: 连续的图片 segment 组合
|
||||
* - block: vote / post_ref / video / file / link / voice 等块级元素
|
||||
*
|
||||
* 与聊天和帖子详情共用同一套分组逻辑。
|
||||
*/
|
||||
export function partitionSegments(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 === 'vote' ||
|
||||
s.type === 'post_ref' ||
|
||||
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 {
|
||||
// 未知类型也当作 inline 处理,避免丢失内容
|
||||
inlineBuf.push(s);
|
||||
}
|
||||
}
|
||||
flushInline();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from message segments for display
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user