文件
mynode/packages/app-mobile/components/screens/NoteRevisionViewer.tsx
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

371 行
12 KiB
TypeScript

import * as React from 'react';
import { connect } from 'react-redux';
import { View, StyleSheet, TextInput, ScrollView, Text as TextNative } from 'react-native';
import { AppState } from '../../utils/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Revision from '@joplin/lib/models/Revision';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { IconButton, Text } from 'react-native-paper';
import Dropdown from '../Dropdown';
import ScreenHeader, { MenuOption } from '../ScreenHeader';
import { formatMsToLocal } from '@joplin/utils/time';
import { useCallback, useContext, useMemo, useState } from 'react';
import { PrimaryButton } from '../buttons';
import { _ } from '@joplin/lib/locale';
import { NoteEntity, RevisionEntity } from '@joplin/lib/services/database/types';
import RevisionService from '@joplin/lib/services/RevisionService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import { MarkupLanguage } from '@joplin/renderer';
import attachedResources, { AttachedResources } from '@joplin/lib/utils/attachedResources';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { themeStyle } from '../global-style';
import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage';
import { DialogContext } from '../DialogManager';
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
import { OnScrollCallback } from '../NoteBodyViewer/types';
import TextWrapCalculator from './Notes/TextWrapCalculator';
import { MenuOptionStyle } from '../BottomDrawerMenu';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockService from '@joplin/lib/services/noteLock/NoteLockService';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockPanel from './Note/NoteLockPanel';
interface Props {
themeId: number;
selectedNoteId: string;
noteLockSessionUnlocked: boolean;
// Properties passed by the navigation logic
navigation?: {
state?: {
noteId: string;
};
};
}
const useRevisions = (noteId: string) => {
const [revisions, setRevisions] = useState<RevisionEntity[]>([]);
useAsyncEffect(async (event) => {
if (!noteId) {
setRevisions([]);
return;
}
const revisions = await Revision.allByType(ModelType.Note, noteId);
if (event.cancelled) return;
setRevisions(revisions);
}, [noteId]);
return revisions;
};
const useRevisionNote = (revisions: RevisionEntity[], revisionId: string, canDecrypt: boolean) => {
const [note, setNote] = useState<NoteEntity|null>(null);
// The revision note as merged from the diffs, with a locked body still encrypted. Restoring
// this keeps the restored copy a valid locked note, so it is never decrypted for restore.
const [restoreNote, setRestoreNote] = useState<NoteEntity|null>(null);
const [decryptFailed, setDecryptFailed] = useState(false);
const [resources, setResources] = useState<AttachedResources>({});
useAsyncEffect(async event => {
// Cleared before the load so a switch never keeps the previous revision's failure or restore target.
setRestoreNote(null);
setDecryptFailed(false);
const revisionIndex = BaseModel.modelIndexById(revisions, revisionId);
if (revisionIndex === -1) {
setNote(null);
setResources({});
return;
}
let note = await RevisionService.instance().revisionNote(revisions, revisionIndex);
if (event.cancelled) return;
const encryptedNote = note;
if (isNoteLockEnabled() && revisions[revisionIndex].is_locked) {
// Revisions are merged before decryption, so a gated load is not possible.
// Keep this in sync with app-desktop/gui/NoteRevisionViewer.tsx.
let displayBody = '';
let failed = false;
if (canDecrypt) {
try {
displayBody = await NoteLockService.instance().decryptString(note.body ?? '');
} catch (error) {
console.warn('Could not decrypt revision content:', error);
failed = true;
}
if (event.cancelled) return;
}
setDecryptFailed(failed);
note = { ...note, body: displayBody };
}
setNote(note);
setRestoreNote(encryptedNote);
const resources = await attachedResources(note?.body ?? '');
if (event.cancelled) return;
setResources(resources);
}, [revisions, revisionId, canDecrypt]);
return { note, restoreNote, decryptFailed, resources };
};
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
noteViewer: {
flex: 1,
},
controls: {
padding: theme.margin,
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 3,
},
dropdownListStyle: {
backgroundColor: theme.backgroundColor,
},
dropdownItemStyle: {
color: theme.color,
fontSize: theme.fontSize,
},
dropdownHeaderStyle: {
color: theme.color,
fontSize: theme.fontSize,
},
root: {
...theme.rootStyle,
},
titleViewContainer: {
paddingLeft: theme.marginLeft,
borderTopColor: theme.dividerColor,
borderTopWidth: 1,
borderBottomColor: theme.dividerColor,
borderBottomWidth: 1,
flex: 0,
flexDirection: 'row',
flexBasis: 'auto',
maxHeight: '40%',
},
titleText: {
flex: 1,
marginTop: 0,
paddingLeft: 0,
color: theme.color,
backgroundColor: theme.backgroundColor,
fontWeight: 'bold',
fontSize: theme.fontSize,
paddingTop: 10,
paddingBottom: 10,
},
});
}, [themeId]);
};
const emptyStringList: string[] = [];
const NoteRevisionViewer: React.FC<Props> = props => {
const noteId = props.navigation?.state?.noteId ?? props.selectedNoteId;
const revisions = useRevisions(noteId);
const [currentRevisionId, setCurrentRevisionId] = useState<string>('');
const hasNoteLockKey = isNoteLockEnabled() && !!NoteLockKey.instance().load();
const canDecrypt = props.noteLockSessionUnlocked && hasNoteLockKey;
const { note, restoreNote, decryptFailed, resources } = useRevisionNote(revisions, currentRevisionId, canDecrypt);
const revisionLocked = isNoteLockEnabled() && revisions.some(r => r.id === currentRevisionId && !!r.is_locked);
const showLockPanel = revisionLocked && (!canDecrypt || decryptFailed);
const [initialScroll, setInitialScroll] = useState(0);
const [hasRevisions, setHasRevisions] = useState(false);
const [multiline, setMultiline] = useState(false);
const [showMultilineToggle, setShowMultilineToggle] = useState<boolean | null>(null);
const [titleContainerWidth, setTitleContainerWidth] = useState(0);
const options = useMemo(() => {
const result = [];
const reversedRevisions = revisions.slice().reverse();
for (const revision of reversedRevisions) {
const stats = Revision.revisionPatchStatsText(revision);
result.push({
label: `${formatMsToLocal(revision.item_updated_time)} (${stats})`,
value: revision.id,
});
}
setHasRevisions(result.length > 0);
return result;
}, [revisions]);
const onScroll: OnScrollCallback = useCallback((event) => {
setInitialScroll(event.fraction);
}, []);
const onOptionSelected = useCallback((value: string) => {
setCurrentRevisionId(value);
}, []);
const [restoring, setRestoring] = useState(false);
const onRestore = useCallback(async () => {
if (!restoreNote) return;
setRestoring(true);
try {
await RevisionService.instance().importRevisionNote(restoreNote);
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(restoreNote), { type: MessageBoxType.Info });
} finally {
setRestoring(false);
}
}, [restoreNote]);
const resetScreenState = useCallback(() => {
setCurrentRevisionId(null);
setHasRevisions(false);
revisions.length = 0;
options.length = 0;
}, [revisions, options]);
const [deleting, setDeleting] = useState(false);
const deleteHistory_onPress = useDeleteHistoryClick({
noteId,
setDeleting,
resetScreenState,
});
const disableDeleteHistory = deleting || !hasRevisions;
const menuOptions = useMemo(() => {
const output: MenuOption[] = [{
title: _('Delete history'),
icon: 'material delete-outline',
style: MenuOptionStyle.Destructive,
onPress: deleteHistory_onPress,
disabled: disableDeleteHistory,
}];
return output;
}, [deleteHistory_onPress, disableDeleteHistory]);
const restoreButtonTitle = _('Restore');
const helpMessageText = getHelpMessage(restoreButtonTitle);
const dialogs = useContext(DialogContext);
const onHelpPress = useCallback(() => {
void dialogs.info(helpMessageText);
}, [helpMessageText, dialogs]);
const onToggleTitlePress = useCallback(() => {
void setMultiline(!multiline);
}, [multiline]);
const styles = useStyles(props.themeId);
const dropdownLabelText = _('Revision:');
const restoreButton = (
<PrimaryButton
onPress={onRestore}
disabled={restoring || !restoreNote || showLockPanel}
>{restoreButtonTitle}</PrimaryButton>
);
const textWrapCalculator_updateState = (showToggle: boolean, enableMultiline: boolean) => {
setShowMultilineToggle(showToggle);
setMultiline(enableMultiline);
};
const titleToggleButton = !showMultilineToggle ? null :
<IconButton
icon={(!multiline && 'menu-down') || (multiline && 'menu-up')}
accessibilityLabel={(!multiline && _('Expand title')) || (multiline && _('Collapse title'))}
onPress={onToggleTitlePress}
size={30}
style={{ width: 30, height: 30, alignSelf: 'center' }}
/>;
const titleComponent = (
<View
style={styles.titleViewContainer}
onLayout={(e) => {
const width = e.nativeEvent.layout.width;
if (width !== titleContainerWidth) {
setTitleContainerWidth(width);
}
}}
>
<TextWrapCalculator
textCompStyle={styles.titleText}
textCompContainerWidth={titleContainerWidth}
showMultilineToggle={showMultilineToggle}
multiline={multiline}
text={note?.title ?? ''}
updateState={textWrapCalculator_updateState}
readOnly={true}
/>
{
multiline ?
<ScrollView
style={{ flex: 1 }}
showsVerticalScrollIndicator={false}
>
<TextNative
selectable
style={styles.titleText}
>
{note?.title ?? ''}
</TextNative>
</ScrollView> :
<TextInput
style={styles.titleText}
value={note?.title ?? ''}
editable={false}
/>
}
{ titleToggleButton }
</View>
);
return <View style={styles.root}>
<ScreenHeader menuOptions={menuOptions} title={_('Note history')} />
<View style={styles.controls}>
<Text variant='labelLarge'>{dropdownLabelText}</Text>
<Dropdown
items={options}
onValueChange={onOptionSelected}
selectedValue={currentRevisionId}
itemListStyle={styles.dropdownListStyle}
headerStyle={styles.dropdownHeaderStyle}
itemStyle={styles.dropdownItemStyle}
accessibilityHint={dropdownLabelText}
defaultHeaderLabel={_('Select a revision...')}
// Hides the restore button when the dropdown is open. This
// is important on small screens where otherwise it's difficult
// to read the content of the revision dropdown.
coverableChildrenRight={restoreButton}
/>
<IconButton
icon='help-circle-outline'
accessibilityLabel={_('Help')}
onPress={onHelpPress}
/>
</View>
{note ? titleComponent : ''}
{showLockPanel ?
<NoteLockPanel
themeId={props.themeId}
hasNoteLockKey={hasNoteLockKey}
undecryptable={decryptFailed && props.noteLockSessionUnlocked}
/> :
<NoteBodyViewer
style={styles.noteViewer}
noteBody={note?.body ?? _('No revision selected')}
noteMarkupLanguage={MarkupLanguage.Markdown}
noteResources={resources}
highlightedKeywords={emptyStringList}
paddingBottom={0}
initialScrollPercent={initialScroll}
onScroll={onScroll}
noteHash={''}
/>
}
</View>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
selectedNoteId: state.selectedNoteIds[0] ?? '',
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
}))(NoteRevisionViewer);