Files
frontend/src/components/business/BlockEditor/useBlockEditor.ts
lan d4c3e1f268
All checks were successful
Frontend CI / ota-android (push) Successful in 1m18s
Frontend CI / ota-ios (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / build-android-apk (push) Successful in 1h18m20s
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.
2026-05-08 01:57:05 +08:00

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,
};
}