You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-10 22:11:50 +02:00
Mobile: Add note revision viewer (#12305)
This commit is contained in:
@@ -806,6 +806,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
|
||||
packages/app-mobile/components/screens/Note/commands/setTags.js
|
||||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
|
||||
packages/app-mobile/components/screens/Note/types.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.js
|
||||
packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.js
|
||||
@@ -1075,6 +1077,7 @@ packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -780,6 +780,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
|
||||
packages/app-mobile/components/screens/Note/commands/setTags.js
|
||||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
|
||||
packages/app-mobile/components/screens/Note/types.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.js
|
||||
packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.js
|
||||
@@ -1049,6 +1051,7 @@ packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||
|
@@ -16,6 +16,7 @@ const urlUtils = require('@joplin/lib/urlUtils');
|
||||
const ReactTooltip = require('react-tooltip');
|
||||
const { connect } = require('react-redux');
|
||||
import shared from '@joplin/lib/components/shared/note-screen-shared';
|
||||
import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import { RefObject, useCallback, useRef, useState } from 'react';
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
@@ -163,7 +164,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
}
|
||||
|
||||
const restoreButtonTitle = _('Restore');
|
||||
const helpMessage = _('Click "%s" to restore the note. It will be copied in the notebook named "%s". The current version of the note will not be replaced or modified.', restoreButtonTitle, RevisionService.instance().restoreFolderTitle());
|
||||
const helpMessage = getHelpMessage(restoreButtonTitle);
|
||||
|
||||
const titleInput = (
|
||||
<div className='revision-viewer-title'>
|
||||
|
@@ -23,6 +23,7 @@ interface DropdownProps {
|
||||
headerStyle?: TextStyle;
|
||||
itemStyle?: TextStyle;
|
||||
disabled?: boolean;
|
||||
defaultHeaderLabel?: string; // Defaults to "..."
|
||||
accessibilityHint?: string;
|
||||
|
||||
labelTransform?: 'trim';
|
||||
@@ -149,7 +150,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||
|
||||
const itemStyle = { ...(this.props.itemStyle ? this.props.itemStyle : {}) };
|
||||
|
||||
let headerLabel = '...';
|
||||
let headerLabel = this.props.defaultHeaderLabel ?? '...';
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.value === this.props.selectedValue) {
|
||||
|
@@ -9,7 +9,7 @@ import NoteBodyViewer from './NoteBodyViewer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
|
||||
import { OnMarkForDownloadCallback } from './hooks/useOnMessage';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
@@ -22,7 +22,6 @@ interface WrapperProps {
|
||||
noteBody: string;
|
||||
highlightedKeywords?: string[];
|
||||
noteResources?: Record<string, ResourceInfo>;
|
||||
onJoplinLinkClick?: HandleMessageCallback;
|
||||
onScroll?: (percent: number)=> void;
|
||||
onMarkForDownload?: OnMarkForDownloadCallback;
|
||||
}
|
||||
@@ -36,16 +35,13 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
noteBody,
|
||||
highlightedKeywords = emptyArray,
|
||||
noteResources = emptyObject,
|
||||
onJoplinLinkClick = noOpFunction,
|
||||
onScroll = noOpFunction,
|
||||
onMarkForDownload,
|
||||
}: WrapperProps,
|
||||
) => {
|
||||
return <TestProviderStack store={testStore}>
|
||||
<NoteBodyViewer
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
style={emptyObject}
|
||||
fontSize={12}
|
||||
noteBody={noteBody}
|
||||
noteMarkupLanguage={MarkupLanguage.Markdown}
|
||||
highlightedKeywords={highlightedKeywords}
|
||||
@@ -53,10 +49,8 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
paddingBottom={0}
|
||||
initialScroll={0}
|
||||
noteHash={''}
|
||||
onJoplinLinkClick={onJoplinLinkClick}
|
||||
onMarkForDownload={onMarkForDownload}
|
||||
onScroll={onScroll}
|
||||
pluginStates={emptyObject}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
@@ -15,6 +15,10 @@ import uuid from '@joplin/lib/uuid';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useContentScripts from './hooks/useContentScripts';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -27,7 +31,6 @@ interface Props {
|
||||
paddingBottom: number;
|
||||
initialScroll: number|null;
|
||||
noteHash: string;
|
||||
onJoplinLinkClick: HandleMessageCallback;
|
||||
onCheckboxChange?: HandleMessageCallback;
|
||||
onRequestEditResource?: HandleMessageCallback;
|
||||
onMarkForDownload?: OnMarkForDownloadCallback;
|
||||
@@ -36,7 +39,15 @@ interface Props {
|
||||
pluginStates: PluginStates;
|
||||
}
|
||||
|
||||
export default function NoteBodyViewer(props: Props) {
|
||||
const onJoplinLinkClick = async (message: string) => {
|
||||
try {
|
||||
await CommandService.instance().execute('openItem', message);
|
||||
} catch (error) {
|
||||
await shim.showErrorDialog(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
function NoteBodyViewer(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const onScroll = useCallback(async (scrollTop: number) => {
|
||||
@@ -45,14 +56,14 @@ export default function NoteBodyViewer(props: Props) {
|
||||
|
||||
const onResourceLongPress = useOnResourceLongPress(
|
||||
{
|
||||
onJoplinLinkClick: props.onJoplinLinkClick,
|
||||
onJoplinLinkClick,
|
||||
onRequestEditResource: props.onRequestEditResource,
|
||||
},
|
||||
);
|
||||
|
||||
const onPostMessage = useOnMessage(props.noteBody, {
|
||||
onMarkForDownload: props.onMarkForDownload,
|
||||
onJoplinLinkClick: props.onJoplinLinkClick,
|
||||
onJoplinLinkClick,
|
||||
onRequestEditResource: props.onRequestEditResource,
|
||||
onCheckboxChange: props.onCheckboxChange,
|
||||
onResourceLongPress,
|
||||
@@ -118,3 +129,9 @@ export default function NoteBodyViewer(props: Props) {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
fontSize: state.settings['style.viewer.fontSize'],
|
||||
pluginStates: state.pluginService.plugins,
|
||||
}))(NoteBodyViewer);
|
||||
|
@@ -159,8 +159,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private noteTagDialog_closeRequested: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private onJoplinLinkClick_: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private refreshResource: (resource: any, noteBody?: string)=> Promise<void>;
|
||||
private selection: SelectionRange;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -287,14 +285,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
this.setState({ noteTagDialogShown: false });
|
||||
};
|
||||
|
||||
this.onJoplinLinkClick_ = async (msg: string) => {
|
||||
try {
|
||||
await CommandService.instance().execute('openItem', msg);
|
||||
} catch (error) {
|
||||
await this.props.dialogs.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.refreshResource = async (resource: any, noteBody: string = null) => {
|
||||
if (noteBody === null && this.state.note && this.state.note.body) noteBody = this.state.note.body;
|
||||
@@ -1091,6 +1081,15 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
void this.showOnMap_onPress();
|
||||
},
|
||||
});
|
||||
output.push({
|
||||
title: _('Previous versions'),
|
||||
onPress: () => {
|
||||
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
void NavService.go('NoteRevisionViewer', {
|
||||
noteId: this.props.noteId,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (note.source_url) {
|
||||
output.push({
|
||||
title: _('Go to source URL'),
|
||||
@@ -1519,7 +1518,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
bodyComponent =
|
||||
!note || !note.body.trim() ? null : (
|
||||
<NoteBodyViewer
|
||||
onJoplinLinkClick={this.onJoplinLinkClick_}
|
||||
style={this.styles().noteBodyViewer}
|
||||
// Extra bottom padding to make it possible to scroll past the
|
||||
// action button (so that it doesn't overlap the text)
|
||||
@@ -1528,15 +1526,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
noteMarkupLanguage={note.markup_language}
|
||||
noteResources={this.state.noteResources}
|
||||
highlightedKeywords={keywords}
|
||||
themeId={this.props.themeId}
|
||||
fontSize={this.props.viewerFontSize}
|
||||
noteHash={this.props.noteHash}
|
||||
onCheckboxChange={this.onBodyViewerCheckboxChange}
|
||||
onMarkForDownload={this.onMarkForDownload}
|
||||
onRequestEditResource={this.onEditResource}
|
||||
onScroll={this.onBodyViewerScroll}
|
||||
initialScroll={this.lastBodyScroll}
|
||||
pluginStates={this.props.plugins}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
@@ -0,0 +1,105 @@
|
||||
import * as React from 'react';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import NoteRevisionViewer from './NoteRevisionViewer';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, revisionService, waitFor } from '@joplin/lib/testing/test-utils';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import '@testing-library/jest-native/extend-expect';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { useMemo } from 'react';
|
||||
import Revision from '@joplin/lib/models/Revision';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||
|
||||
interface WrapperProps {
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedRevisionViewerScreen: React.FC<WrapperProps> = ({ noteId }) => {
|
||||
const navigationState = useMemo(() => ({
|
||||
state: { noteId },
|
||||
}), [noteId]);
|
||||
|
||||
return <TestProviderStack store={store}>
|
||||
<NoteRevisionViewer
|
||||
navigation={navigationState}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const createNoteWithTestRevisions = async (count: number) => {
|
||||
const note = await Note.save({ title: 'Note', body: 'Test', parent_id: '' });
|
||||
const noteId = note.id;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
jest.advanceTimersByTime(1000 * 60 * 10);
|
||||
await Note.save({
|
||||
id: noteId,
|
||||
title: `Note - Updated (x${i + 1})`,
|
||||
body: `Update ${i + 1}`,
|
||||
});
|
||||
await revisionService().collectRevisions();
|
||||
}
|
||||
|
||||
// Verify that the revisions were created successfully
|
||||
expect(await Revision.allByType(ModelType.Note, noteId)).toHaveLength(count);
|
||||
return note;
|
||||
};
|
||||
|
||||
const getRevisionViewerDom = async () => {
|
||||
return await getWebViewDomById('NoteBodyViewer');
|
||||
};
|
||||
|
||||
const getRevisionViewerText = async () => {
|
||||
// Use #rendered-md and not body. With jsdom, 'body' has
|
||||
// CSS in its .textContent.
|
||||
const mainContent = (await getRevisionViewerDom()).querySelector('#rendered-md');
|
||||
return mainContent.textContent.trim();
|
||||
};
|
||||
|
||||
describe('screens/NoteRevisionViewer', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
screen.unmount();
|
||||
});
|
||||
|
||||
test('should render "No revision selected" when no revisions are selected', async () => {
|
||||
const note = await createNoteWithTestRevisions(3);
|
||||
const { unmount } = render(<WrappedRevisionViewerScreen noteId={note.id}/>);
|
||||
|
||||
expect(await getRevisionViewerText()).toBe('No revision selected');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('selecting a revision should render its content', async () => {
|
||||
const note = await createNoteWithTestRevisions(3);
|
||||
const { unmount } = render(<WrappedRevisionViewerScreen noteId={note.id}/>);
|
||||
|
||||
const dropdown = screen.getByRole('button', { name: 'Select a revision...' });
|
||||
fireEvent.press(dropdown);
|
||||
|
||||
// Select the second revision
|
||||
await act(() => waitFor(async () => {
|
||||
const firstRevision = screen.getAllByRole('menuitem')[1];
|
||||
fireEvent.press(firstRevision);
|
||||
}));
|
||||
|
||||
await act(() => waitFor(async () => {
|
||||
expect(await getRevisionViewerText()).toBe('Update 2');
|
||||
}));
|
||||
unmount();
|
||||
});
|
||||
});
|
196
packages/app-mobile/components/screens/NoteRevisionViewer.tsx
Normal file
196
packages/app-mobile/components/screens/NoteRevisionViewer.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { View, StyleSheet } 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 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';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
selectedNoteId: string;
|
||||
|
||||
// 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) => {
|
||||
const [note, setNote] = useState<NoteEntity|null>(null);
|
||||
const [resources, setResources] = useState<AttachedResources>({});
|
||||
|
||||
useAsyncEffect(async event => {
|
||||
const revisionIndex = BaseModel.modelIndexById(revisions, revisionId);
|
||||
if (revisionIndex === -1) {
|
||||
setNote(null);
|
||||
return;
|
||||
}
|
||||
const note = await RevisionService.instance().revisionNote(revisions, revisionIndex);
|
||||
if (event.cancelled) return;
|
||||
setNote(note);
|
||||
|
||||
const resources = await attachedResources(note?.body ?? '');
|
||||
if (event.cancelled) return;
|
||||
setResources(resources);
|
||||
}, [revisions, revisionId]);
|
||||
|
||||
return { note, 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,
|
||||
},
|
||||
});
|
||||
}, [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 { note, resources } = useRevisionNote(revisions, currentRevisionId);
|
||||
const [initialScroll, setInitialScroll] = useState(0);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const result = [];
|
||||
for (const revision of revisions) {
|
||||
const stats = Revision.revisionPatchStatsText(revision);
|
||||
result.push({
|
||||
label: `${formatMsToLocal(revision.item_updated_time)} (${stats})`,
|
||||
value: revision.id,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [revisions]);
|
||||
|
||||
const onOptionSelected = useCallback((value: string) => {
|
||||
setCurrentRevisionId(value);
|
||||
}, []);
|
||||
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const onRestore = useCallback(async () => {
|
||||
if (!note) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
await RevisionService.instance().importRevisionNote(note);
|
||||
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(note), { type: MessageBoxType.Info });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
const restoreButtonTitle = _('Restore');
|
||||
const helpMessageText = getHelpMessage(restoreButtonTitle);
|
||||
const dialogs = useContext(DialogContext);
|
||||
const onHelpPress = useCallback(() => {
|
||||
void dialogs.info(helpMessageText);
|
||||
}, [helpMessageText, dialogs]);
|
||||
|
||||
const styles = useStyles(props.themeId);
|
||||
const dropdownLabelText = _('Revision:');
|
||||
|
||||
return <View style={styles.root}>
|
||||
<ScreenHeader 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...')}
|
||||
/>
|
||||
<PrimaryButton
|
||||
onPress={onRestore}
|
||||
disabled={restoring || !note}
|
||||
>{restoreButtonTitle}</PrimaryButton>
|
||||
<IconButton
|
||||
icon='help-circle-outline'
|
||||
accessibilityLabel={_('Help')}
|
||||
onPress={onHelpPress}
|
||||
/>
|
||||
</View>
|
||||
<NoteBodyViewer
|
||||
style={styles.noteViewer}
|
||||
noteBody={note?.body ?? _('No revision selected')}
|
||||
noteMarkupLanguage={MarkupLanguage.Markdown}
|
||||
noteResources={resources}
|
||||
highlightedKeywords={emptyStringList}
|
||||
paddingBottom={0}
|
||||
initialScroll={initialScroll}
|
||||
onScroll={setInitialScroll}
|
||||
noteHash={''}
|
||||
/>
|
||||
</View>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
selectedNoteId: state.selectedNoteIds[0] ?? '',
|
||||
}))(NoteRevisionViewer);
|
@@ -141,6 +141,7 @@ import { AppState } from './utils/types';
|
||||
import { getDisplayParentId } from '@joplin/lib/services/trash';
|
||||
import PluginNotification from './components/plugins/PluginNotification';
|
||||
import FocusControl from './components/accessibility/FocusControl/FocusControl';
|
||||
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
||||
|
||||
const logger = Logger.create('root');
|
||||
|
||||
@@ -1306,6 +1307,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
ShareManager: { screen: ShareManager },
|
||||
ProfileSwitcher: { screen: ProfileSwitcher },
|
||||
ProfileEditor: { screen: ProfileEditor },
|
||||
NoteRevisionViewer: { screen: NoteRevisionViewer },
|
||||
Log: { screen: LogScreen },
|
||||
Status: { screen: StatusScreen },
|
||||
Search: { screen: SearchScreen },
|
||||
|
@@ -0,0 +1,8 @@
|
||||
import { _ } from '../../../locale';
|
||||
import RevisionService from '../../../services/RevisionService';
|
||||
|
||||
const getHelpMessage = (restoreButtonTitle: string) => {
|
||||
return _('Click "%s" to restore the note. It will be copied in the notebook named "%s". The current version of the note will not be replaced or modified.', restoreButtonTitle, RevisionService.instance().restoreFolderTitle());
|
||||
};
|
||||
|
||||
export default getHelpMessage;
|
@@ -1,6 +1,8 @@
|
||||
import BaseModel from '../BaseModel';
|
||||
import Note from '../models/Note';
|
||||
import Resource from '../models/Resource';
|
||||
import ResourceLocalState from '../models/ResourceLocalState';
|
||||
import { ResourceEntity } from '../services/database/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let resourceCache_: any = {};
|
||||
@@ -9,8 +11,15 @@ export function clearResourceCache() {
|
||||
resourceCache_ = {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default async function attachedResources(noteBody: string): Promise<any> {
|
||||
interface AttachedResource {
|
||||
item: ResourceEntity;
|
||||
localState: ResourceLocalState;
|
||||
}
|
||||
export interface AttachedResources {
|
||||
[id: string]: AttachedResource;
|
||||
}
|
||||
|
||||
export default async function attachedResources(noteBody: string): Promise<AttachedResources> {
|
||||
if (!noteBody) return {};
|
||||
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);
|
||||
|
||||
|
@@ -3,7 +3,9 @@
|
||||
// added here, and should be based on dayjs (not moment)
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
import * as dayjs from 'dayjs';
|
||||
import type * as dayjsImport from 'dayjs';
|
||||
// A require() is needed here for this to work in React Native.
|
||||
const dayjs: typeof dayjsImport = require('dayjs');
|
||||
|
||||
// Separating this into a type import and a require seems to be necessary to support mobile:
|
||||
// - import = require syntax doesn't work when bundling
|
||||
|
Reference in New Issue
Block a user