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.
289 lines
9.0 KiB
TypeScript
289 lines
9.0 KiB
TypeScript
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,
|
|
};
|
|
} |