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([createInitialTextBlock()]); const [activeBlockId, setActiveBlockId] = useState(null); const inputRefs = useRef>(new Map()); const blockCursors = useRef>(new Map()); const updateTextBlock = useCallback( (blockId: string, text: string, mentions: Map) => { 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(); 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, }; }