MP: full document editing

This commit is contained in:
Enrico Ros
2024-06-22 17:01:50 -07:00
parent 2e7b5ba5f0
commit 7bb0fb294a
4 changed files with 61 additions and 59 deletions
@@ -9,7 +9,7 @@ import EditRoundedIcon from '@mui/icons-material/EditRounded';
import { BlocksRenderer } from '~/modules/blocks/BlocksRenderer';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageAttachmentFragment, DMessageFragmentId, DMessageRole } from '~/common/stores/chat/chat.message';
import { createTextAttachmentFragment, DMessageAttachmentFragment, DMessageFragmentId, DMessageRole } from '~/common/stores/chat/chat.message';
import { marshallWrapText } from '~/common/stores/chat/chat.tokens';
import { ContentPartTextEdit } from '../fragments-content/ContentPartTextEdit';
@@ -28,7 +28,7 @@ export function DocumentFragmentEditor(props: {
}) {
// derived state
const { fragment, onFragmentDelete, onFragmentReplace } = props;
const { editedText, fragment, onFragmentDelete, onFragmentReplace } = props;
const [isEditing, setIsEditing] = React.useState(false);
const [isDeleteArmed, setIsDeleteArmed] = React.useState(false);
@@ -36,34 +36,39 @@ export function DocumentFragmentEditor(props: {
const fragmentTitle = fragment.title;
const part = fragment.part;
// handlers
const handleDeleteFragment = React.useCallback(() => {
onFragmentDelete(fragmentId);
}, [fragmentId, onFragmentDelete]);
const handleReplaceFragment = React.useCallback((newFragment: DMessageAttachmentFragment) => {
onFragmentReplace(fragmentId, newFragment);
}, [fragmentId, onFragmentReplace]);
if (part.pt !== 'text')
throw new Error('Unexpected part type: ' + part.pt);
const handleEditToggle = React.useCallback(() => {
// delete
const handleToggleDeleteArmed = React.useCallback(() => {
// setIsEditing(false);
setIsDeleteArmed(on => !on);
}, []);
const handleFragmentDelete = React.useCallback(() => {
onFragmentDelete(fragmentId);
}, [fragmentId, onFragmentDelete]);
// edit
const handleToggleEdit = React.useCallback(() => {
setIsDeleteArmed(false);
setIsEditing(on => !on);
}, []);
const handleEditEnterPressed = React.useCallback(() => {
// setIsEditing(false);
// TODO...
}, []);
const handleDeleteArmedToggle = React.useCallback(() => {
setIsEditing(false);
setIsDeleteArmed(on => !on);
}, []);
const handleEditApply = React.useCallback(() => {
setIsDeleteArmed(false);
if (editedText === undefined)
return;
if (editedText?.length > 0) {
onFragmentReplace(fragmentId, createTextAttachmentFragment(fragmentTitle, editedText));
// NOTE: since the former function changes the ID of the fragment, the
// whole editor will disappear as a side effect
} else
handleFragmentDelete();
}, [editedText, fragmentId, fragmentTitle, handleFragmentDelete, onFragmentReplace]);
return (
@@ -85,8 +90,8 @@ export function DocumentFragmentEditor(props: {
contentScaling={props.contentScaling}
editedText={props.editedText}
setEditedText={props.setEditedText}
onEnterPressed={handleEditEnterPressed}
onEscapePressed={handleEditToggle}
onEnterPressed={handleEditApply}
onEscapePressed={handleToggleEdit}
/>
) : (
// Document viewer, including collapse/expand
@@ -103,19 +108,18 @@ export function DocumentFragmentEditor(props: {
{/* Edit / Delete commands */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', mt: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
{isDeleteArmed ? (
<Button variant='solid' color='neutral' size='sm' onClick={handleDeleteArmedToggle} startDecorator={<CloseRoundedIcon />}>
<Button variant='solid' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<CloseRoundedIcon />}>
Cancel
</Button>
) : (
<Button variant='plain' color='neutral' size='sm' onClick={handleDeleteArmedToggle} startDecorator={<DeleteOutlineIcon />}>
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<DeleteOutlineIcon />}>
Delete
</Button>
)}
{isDeleteArmed && (
<Button variant='plain' color='danger' size='sm' onClick={handleDeleteFragment} startDecorator={<DeleteForeverIcon />}>
<Button variant='plain' color='danger' size='sm' onClick={handleFragmentDelete} startDecorator={<DeleteForeverIcon />}>
Delete
</Button>
)}
@@ -123,24 +127,22 @@ export function DocumentFragmentEditor(props: {
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
{isEditing ? (
<Button variant='plain' color='neutral' size='sm' onClick={handleEditToggle} startDecorator={<CloseRoundedIcon />}>
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<CloseRoundedIcon />}>
Cancel
</Button>
) : (
<Button variant='plain' color='neutral' size='sm' onClick={handleEditToggle} startDecorator={<EditRoundedIcon />}>
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<EditRoundedIcon />}>
Edit
</Button>
)}
{isEditing && (
<Button variant='plain' color='success' onClick={undefined} size='sm' startDecorator={<CheckRoundedIcon />}>
<Button variant='plain' color='success' onClick={handleEditApply} size='sm' startDecorator={<CheckRoundedIcon />}>
Save
</Button>
)}
</Box>
</Box>
</Box>
);
}
@@ -25,26 +25,26 @@ export function DocumentFragments(props: {
}) {
// state
const [selectedFragmentId, setSelectedFragmentId] = React.useState<DMessageFragmentId | null>(null);
const [textAttachmentsEditState, setTextAttachmentsEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
const [activeFragmentId, setActiveFragmentId] = React.useState<DMessageFragmentId | null>(null);
const [editState, setEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
// selection
const handleToggleSelectedId = React.useCallback((fragmentId: DMessageFragmentId) => setActiveFragmentId(prevId => prevId === fragmentId ? null : fragmentId), []);
const selectedFragment = props.attachmentFragments.find(fragment => fragment.fId === activeFragmentId);
// editing
const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, value: string) => setEditState(prevState => ({ ...prevState, [fragmentId]: value })), []);
// [effect] clear edits on onmount
React.useEffect(() => {
return () => setTextAttachmentsEditState(null);
return () => setEditState(null);
}, []);
const handleToggleSelected = React.useCallback((fragmentId: DMessageFragmentId) => {
setSelectedFragmentId(prevId => prevId === fragmentId ? null : fragmentId);
}, []);
const handleSetEditedText = React.useCallback((fragmentId: DMessageFragmentId, value: string) => {
setTextAttachmentsEditState(prevState => ({
...prevState,
[fragmentId]: value,
}));
}, []);
const selectedFragment = props.attachmentFragments.find(fragment => fragment.fId === selectedFragmentId);
return (
<Box aria-label={`${props.attachmentFragments.length} attachments`} sx={{
@@ -68,8 +68,8 @@ export function DocumentFragments(props: {
key={attachmentFragment.fId}
fragment={attachmentFragment}
contentScaling={props.contentScaling}
isSelected={selectedFragmentId === attachmentFragment.fId}
toggleSelected={handleToggleSelected}
isSelected={activeFragmentId === attachmentFragment.fId}
toggleSelected={handleToggleSelectedId}
/>,
)}
</Box>
@@ -79,8 +79,8 @@ export function DocumentFragments(props: {
<DocumentFragmentEditor
fragment={selectedFragment}
messageRole={props.messageRole}
editedText={textAttachmentsEditState?.[selectedFragment.fId]}
setEditedText={handleSetEditedText}
editedText={editState?.[selectedFragment.fId]}
setEditedText={handleEditSetText}
contentScaling={props.contentScaling}
isMobile={props.isMobile}
renderTextAsMarkdown={props.renderTextAsMarkdown}
@@ -316,12 +316,12 @@ export async function attachmentPerformConversion(
// text as-is
case 'text':
newFragments.push(createTextAttachmentFragment(inputDataToString(input.data), ref));
newFragments.push(createTextAttachmentFragment(ref, inputDataToString(input.data)));
break;
// html as-is
case 'rich-text':
newFragments.push(createTextAttachmentFragment(input.altData!, ref || '\n<!DOCTYPE html>'));
newFragments.push(createTextAttachmentFragment(ref || '\n<!DOCTYPE html>', input.altData!));
break;
// html to markdown table
@@ -333,7 +333,7 @@ export async function attachmentPerformConversion(
// fallback to text/plain
mdTable = inputDataToString(input.data);
}
newFragments.push(createTextAttachmentFragment(mdTable, ref));
newFragments.push(createTextAttachmentFragment(ref, mdTable));
break;
// image resized (default mime/quality, openai-high-res)
@@ -397,7 +397,7 @@ export async function attachmentPerformConversion(
},
});
const imageText = result.data.text;
newFragments.push(createTextAttachmentFragment(imageText, ref));
newFragments.push(createTextAttachmentFragment(ref, imageText));
} catch (error) {
console.error(error);
}
@@ -413,7 +413,7 @@ export async function attachmentPerformConversion(
// duplicate the ArrayBuffer to avoid mutation
const pdfData = new Uint8Array(input.data.slice(0));
const pdfText = await pdfToText(pdfData);
newFragments.push(createTextAttachmentFragment(pdfText, ref));
newFragments.push(createTextAttachmentFragment(ref, pdfText));
break;
// pdf to images
+1 -1
View File
@@ -188,7 +188,7 @@ function createContentFragment(part: DMessageContentFragment['part']): DMessageC
}
export function createTextAttachmentFragment(text: string, title: string): DMessageAttachmentFragment {
export function createTextAttachmentFragment(title: string, text: string): DMessageAttachmentFragment {
return createAttachmentFragment(title, createDMessageTextPart(text));
}