1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Mobile: Resolves #2595: Add undo/redo support

This commit is contained in:
Laurent Cozic 2020-06-13 16:20:18 +01:00
parent 341c9ba64b
commit 9a9cfbd130
8 changed files with 353 additions and 17 deletions

View File

@ -115,6 +115,7 @@ ReactNativeClient/lib/services/keychain/KeychainServiceDriver.node.js
ReactNativeClient/lib/services/keychain/KeychainServiceDriverBase.js
ReactNativeClient/lib/services/ResourceEditWatcher.js
ReactNativeClient/lib/services/SettingUtils.js
ReactNativeClient/lib/services/UndoRedoService.js
ReactNativeClient/lib/ShareExtension.js
ReactNativeClient/lib/shareHandler.js
ReactNativeClient/PluginAssetsLoader.js

1
.gitignore vendored
View File

@ -105,6 +105,7 @@ ReactNativeClient/lib/services/keychain/KeychainServiceDriver.node.js
ReactNativeClient/lib/services/keychain/KeychainServiceDriverBase.js
ReactNativeClient/lib/services/ResourceEditWatcher.js
ReactNativeClient/lib/services/SettingUtils.js
ReactNativeClient/lib/services/UndoRedoService.js
ReactNativeClient/lib/ShareExtension.js
ReactNativeClient/lib/shareHandler.js
ReactNativeClient/PluginAssetsLoader.js

View File

@ -0,0 +1,86 @@
// /* eslint-disable no-unused-vars */
// require('app-module-path').addPath(__dirname);
// const { asyncTest, fileContentEqual, setupDatabase, checkThrow, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
// const KvStore = require('lib/services/KvStore.js');
// const UndoRedoService = require('lib/services/UndoRedoService.js').default;
// process.on('unhandledRejection', (reason, p) => {
// console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
// });
// jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
// describe('services_UndoRedoService', function() {
// beforeEach(async (done) => {
// await setupDatabaseAndSynchronizer(1);
// await switchClient(1);
// done();
// });
// it('should undo and redo', asyncTest(async () => {
// const service = new UndoRedoService();
// expect(service.canUndo).toBe(false);
// expect(service.canRedo).toBe(false);
// service.push('test');
// expect(service.canUndo).toBe(true);
// expect(service.canRedo).toBe(false);
// service.push('test 2');
// service.push('test 3');
// expect(service.undo()).toBe('test 3');
// expect(service.canRedo).toBe(true);
// expect(service.undo()).toBe('test 2');
// expect(service.undo()).toBe('test');
// expect(checkThrow(() => service.undo())).toBe(true);
// expect(service.canUndo).toBe(false);
// expect(service.canRedo).toBe(true);
// expect(service.redo()).toBe('test');
// expect(service.canUndo).toBe(true);
// expect(service.redo()).toBe('test 2');
// expect(service.redo()).toBe('test 3');
// expect(service.canRedo).toBe(false);
// expect(checkThrow(() => service.redo())).toBe(true);
// }));
// it('should clear the redo stack when undoing', asyncTest(async () => {
// const service = new UndoRedoService();
// service.push('test');
// service.push('test 2');
// service.push('test 3');
// service.undo();
// expect(service.canRedo).toBe(true);
// service.push('test 4');
// expect(service.canRedo).toBe(false);
// expect(service.undo()).toBe('test 4');
// expect(service.undo()).toBe('test 2');
// }));
// it('should limit the size of the undo stack', asyncTest(async () => {
// const service = new UndoRedoService();
// for (let i = 0; i < 30; i++) {
// service.push(`test${i}`);
// }
// for (let i = 0; i < 20; i++) {
// service.undo();
// }
// expect(service.canUndo).toBe(false);
// }));
// });

View File

@ -378,6 +378,16 @@ async function checkThrowAsync(asyncFn) {
return hasThrown;
}
function checkThrow(fn) {
let hasThrown = false;
try {
fn();
} catch (error) {
hasThrown = true;
}
return hasThrown;
}
function fileContentEqual(path1, path2) {
const fs = require('fs-extra');
const content1 = fs.readFileSync(path1, 'base64');
@ -563,4 +573,4 @@ class TestApp extends BaseApplication {
}
}
module.exports = { kvStore, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
module.exports = { kvStore, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };

View File

@ -7,6 +7,11 @@ export interface QueueItem {
context: any,
}
export enum IntervalType {
Debounce = 1,
Fixed = 2,
}
// The AsyncActionQueue can be used to debounce asynchronous actions, to make sure
// they run in the right order, and also to ensure that if multiple actions are emitted
// only the last one is executed. This is particularly useful to save data in the background.
@ -15,12 +20,14 @@ export default class AsyncActionQueue {
queue_:QueueItem[] = [];
interval_:number;
intervalType_:number;
scheduleProcessingIID_:any = null;
processing_ = false;
needProcessing_ = false;
constructor(interval:number = 100) {
constructor(interval:number = 100, intervalType:IntervalType = IntervalType.Debounce) {
this.interval_ = interval;
this.intervalType_ = intervalType;
}
push(action:QueueItemAction, context:any = null) {
@ -39,6 +46,7 @@ export default class AsyncActionQueue {
if (interval === null) interval = this.interval_;
if (this.scheduleProcessingIID_) {
if (this.intervalType_ === IntervalType.Fixed) return;
clearTimeout(this.scheduleProcessingIID_);
}
@ -67,6 +75,16 @@ export default class AsyncActionQueue {
this.processing_ = false;
}
async reset() {
if (this.scheduleProcessingIID_) {
clearTimeout(this.scheduleProcessingIID_);
this.scheduleProcessingIID_ = null;
}
this.queue_ = [];
return this.waitForAllDone();
}
// Currently waitForAllDone() already finishes all the actions
// as quickly as possible so we can make it an alias.
async processAllNow() {

View File

@ -61,8 +61,8 @@ class ScreenHeaderComponent extends React.PureComponent {
iconButton: {
flex: 1,
backgroundColor: theme.backgroundColor2,
paddingLeft: 15,
paddingRight: 15,
paddingLeft: 10,
paddingRight: 10,
paddingTop: PADDING_V,
paddingBottom: PADDING_V,
},
@ -248,6 +248,36 @@ class ScreenHeaderComponent extends React.PureComponent {
);
}
const renderTopButton = (options) => {
if (!options.visible) return null;
const icon = <Icon name={options.iconName} style={this.styles().topIcon} />;
const viewStyle = options.disabled ? this.styles().iconButtonDisabled : this.styles().iconButton;
return (
<TouchableOpacity onPress={options.onPress} style={{ padding: 0 }} disabled={!!options.disabled}>
<View style={viewStyle}>{icon}</View>
</TouchableOpacity>
);
};
const renderUndoButton = () => {
return renderTopButton({
iconName: 'md-undo',
onPress: this.props.onUndoButtonPress,
visible: this.props.showUndoButton,
disabled: this.props.undoButtonDisabled,
});
};
const renderRedoButton = () => {
return renderTopButton({
iconName: 'md-redo',
onPress: this.props.onRedoButtonPress,
visible: this.props.showRedoButton,
});
};
function selectAllButton(styles, onPress) {
return (
<TouchableOpacity onPress={onPress}>
@ -462,6 +492,8 @@ class ScreenHeaderComponent extends React.PureComponent {
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{sideMenuComp}
{backButtonComp}
{renderUndoButton(this.styles())}
{renderRedoButton(this.styles())}
{saveButton(
this.styles(),
() => {

View File

@ -8,6 +8,7 @@ const { uuid } = require('lib/uuid.js');
const { MarkdownEditor } = require('../../../MarkdownEditor/index.js');
const RNFS = require('react-native-fs');
const Note = require('lib/models/Note.js');
const UndoRedoService = require('lib/services/UndoRedoService.js').default;
const BaseItem = require('lib/models/BaseItem.js');
const Setting = require('lib/models/Setting.js');
const Resource = require('lib/models/Resource.js');
@ -70,9 +71,14 @@ class NoteScreenComponent extends BaseScreenComponent {
// margin. This forces RN to update the text input and to display it. Maybe that hack can be removed once RN is upgraded.
// See https://github.com/laurent22/joplin/issues/1057
HACK_webviewLoadingState: 0,
};
this.selection = null;
undoRedoButtonState: {
canUndo: false,
canRedo: false,
},
selection: { start: 0, end: 0 },
};
this.saveActionQueues_ = {};
@ -122,6 +128,8 @@ class NoteScreenComponent extends BaseScreenComponent {
mode: 'view',
});
await this.undoRedoService_.reset();
return true;
}
@ -216,6 +224,39 @@ class NoteScreenComponent extends BaseScreenComponent {
this.todoCheckbox_change = this.todoCheckbox_change.bind(this);
this.titleTextInput_contentSizeChange = this.titleTextInput_contentSizeChange.bind(this);
this.title_changeText = this.title_changeText.bind(this);
this.undoRedoService_stackChange = this.undoRedoService_stackChange.bind(this);
this.screenHeader_undoButtonPress = this.screenHeader_undoButtonPress.bind(this);
this.screenHeader_redoButtonPress = this.screenHeader_redoButtonPress.bind(this);
this.body_selectionChange = this.body_selectionChange.bind(this);
}
undoRedoService_stackChange() {
this.setState({ undoRedoButtonState: {
canUndo: this.undoRedoService_.canUndo,
canRedo: this.undoRedoService_.canRedo,
} });
}
async undoRedo(type) {
const undoState = await this.undoRedoService_[type](this.undoState());
if (!undoState) return;
this.setState((state) => {
const newNote = Object.assign({}, state.note);
newNote.body = undoState.body;
return {
note: newNote,
selection: Object.assign({}, undoState.selection),
};
});
}
screenHeader_undoButtonPress() {
this.undoRedo('undo');
}
screenHeader_redoButtonPress() {
this.undoRedo('redo');
}
styles() {
@ -307,9 +348,14 @@ class NoteScreenComponent extends BaseScreenComponent {
return shared.isModified(this);
}
async UNSAFE_componentWillMount() {
this.selection = null;
undoState(noteBody = null) {
return {
body: noteBody === null ? this.state.note.body : noteBody,
selection: Object.assign({}, this.state.selection),
};
}
async componentDidMount() {
BackButtonService.addHandler(this.backHandler);
NavService.addHandler(this.navHandler);
@ -318,6 +364,9 @@ class NoteScreenComponent extends BaseScreenComponent {
await shared.initState(this);
this.undoRedoService_ = new UndoRedoService();
this.undoRedoService_.on('stackChange', this.undoRedoService_stackChange);
if (this.state.note && this.state.note.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(this.state.note.body);
await ResourceFetcher.instance().markForDownload(resourceIds);
@ -353,6 +402,8 @@ class NoteScreenComponent extends BaseScreenComponent {
}
this.saveActionQueue(this.state.note.id).processAllNow();
this.undoRedoService_.off('stackChange', this.undoRedoService_stackChange);
}
title_changeText(text) {
@ -362,10 +413,19 @@ class NoteScreenComponent extends BaseScreenComponent {
}
body_changeText(text) {
if (!this.undoRedoService_.canUndo) {
this.undoRedoService_.push(this.undoState());
} else {
this.undoRedoService_.schedulePush(this.undoState());
}
shared.noteComponent_change(this, 'body', text);
this.scheduleSave();
}
body_selectionChange(event) {
this.setState({ selection: event.nativeEvent.selection });
}
makeSaveAction() {
return async () => {
return shared.saveNoteButton_press(this);
@ -584,9 +644,9 @@ class NoteScreenComponent extends BaseScreenComponent {
const newNote = Object.assign({}, this.state.note);
if (this.state.mode == 'edit' && !Setting.value('editor.beta') && !!this.selection) {
const prefix = newNote.body.substring(0, this.selection.start);
const suffix = newNote.body.substring(this.selection.end);
if (this.state.mode == 'edit' && !Setting.value('editor.beta') && !!this.state.selection) {
const prefix = newNote.body.substring(0, this.state.selection.start);
const suffix = newNote.body.substring(this.state.selection.end);
newNote.body = `${prefix}\n${resourceTag}\n${suffix}`;
} else {
newNote.body += `\n${resourceTag}`;
@ -1022,9 +1082,8 @@ class NoteScreenComponent extends BaseScreenComponent {
multiline={true}
value={note.body}
onChangeText={(text) => this.body_changeText(text)}
onSelectionChange={({ nativeEvent: { selection } }) => {
this.selection = selection;
}}
selection={this.state.selection}
onSelectionChange={this.body_selectionChange}
blurOnSubmit={false}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
@ -1056,7 +1115,7 @@ class NoteScreenComponent extends BaseScreenComponent {
// Save button is not really needed anymore with the improved save logic
const showSaveButton = false; // this.state.mode == 'edit' || this.isModified() || this.saveButtonHasBeenShown_;
const saveButtonDisabled = !this.isModified();
const saveButtonDisabled = true;// !this.isModified();
if (showSaveButton) this.saveButtonHasBeenShown_ = true;
@ -1067,7 +1126,20 @@ class NoteScreenComponent extends BaseScreenComponent {
const titleComp = (
<View style={titleContainerStyle}>
{isTodo && <Checkbox style={this.styles().checkbox} checked={!!Number(note.todo_completed)} onChange={this.todoCheckbox_change} />}
<TextInput onContentSizeChange={this.titleTextInput_contentSizeChange} multiline={this.enableMultilineTitle_} ref="titleTextField" underlineColorAndroid="#ffffff00" autoCapitalize="sentences" style={this.styles().titleTextInput} value={note.title} onChangeText={this.title_changeText} selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} placeholder={_('Add title')} placeholderTextColor={theme.colorFaded} />
<TextInput
onContentSizeChange={this.titleTextInput_contentSizeChange}
multiline={this.enableMultilineTitle_}
ref="titleTextField"
underlineColorAndroid="#ffffff00"
autoCapitalize="sentences"
style={this.styles().titleTextInput}
value={note.title}
onChangeText={this.title_changeText}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
placeholder={_('Add title')}
placeholderTextColor={theme.colorFaded}
/>
</View>
);
@ -1075,7 +1147,20 @@ class NoteScreenComponent extends BaseScreenComponent {
return (
<View style={this.rootStyle(this.props.theme).root}>
<ScreenHeader folderPickerOptions={this.folderPickerOptions()} menuOptions={this.menuOptions()} showSaveButton={showSaveButton} saveButtonDisabled={saveButtonDisabled} onSaveButtonPress={this.saveNoteButton_press} showSideMenuButton={false} showSearchButton={false} />
<ScreenHeader
folderPickerOptions={this.folderPickerOptions()}
menuOptions={this.menuOptions()}
showSaveButton={showSaveButton}
saveButtonDisabled={saveButtonDisabled}
onSaveButtonPress={this.saveNoteButton_press}
showSideMenuButton={false}
showSearchButton={false}
showUndoButton={this.state.undoRedoButtonState.canUndo || this.state.undoRedoButtonState.canRedo}
showRedoButton={this.state.undoRedoButtonState.canRedo}
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
onUndoButtonPress={this.screenHeader_undoButtonPress}
onRedoButtonPress={this.screenHeader_redoButtonPress}
/>
{titleComp}
{bodyComponent}
{!Setting.value('editor.beta') && actionButtonComp}

View File

@ -0,0 +1,103 @@
import AsyncActionQueue from '../AsyncActionQueue';
const EventEmitter = require('events');
class UndoQueue {
private inner_:any[] = [];
private size_:number = 20;
pop() {
return this.inner_.pop();
}
push(e:any) {
this.inner_.push(e);
while (this.length > this.size_) {
this.inner_.splice(0,1);
}
}
get length():number {
return this.inner_.length;
}
at(index:number):any {
return this.inner_[index];
}
}
export default class UndoRedoService {
private pushAsyncQueue:AsyncActionQueue = new AsyncActionQueue(700);
private undoStates:UndoQueue = new UndoQueue();
private redoStates:UndoQueue = new UndoQueue();
private eventEmitter:any = new EventEmitter();
private isUndoing:boolean = false;
constructor() {
this.push = this.push.bind(this);
}
on(eventName:string, callback:Function) {
return this.eventEmitter.on(eventName, callback);
}
off(eventName:string, callback:Function) {
return this.eventEmitter.removeListener(eventName, callback);
}
push(state:any) {
this.undoStates.push(state);
this.redoStates = new UndoQueue();
this.eventEmitter.emit('stackChange');
}
schedulePush(state:any) {
this.pushAsyncQueue.push(async () => {
this.push(state);
});
}
async undo(redoState:any) {
if (this.isUndoing) return;
if (!this.canUndo) throw new Error('Nothing to undo');
this.isUndoing = true;
await this.pushAsyncQueue.processAllNow();
const state = this.undoStates.pop();
this.redoStates.push(redoState);
this.eventEmitter.emit('stackChange');
this.isUndoing = false;
return state;
}
async redo(undoState:any) {
if (this.isUndoing) return;
if (!this.canRedo) throw new Error('Nothing to redo');
this.isUndoing = true;
await this.pushAsyncQueue.processAllNow();
const state = this.redoStates.pop();
this.undoStates.push(undoState);
this.eventEmitter.emit('stackChange');
this.isUndoing = false;
return state;
}
async reset() {
this.undoStates = new UndoQueue();
this.redoStates = new UndoQueue();
this.isUndoing = false;
const output = this.pushAsyncQueue.reset();
this.eventEmitter.emit('stackChange');
return output;
}
get canUndo():boolean {
return !!this.undoStates.length;
}
get canRedo():boolean {
return !!this.redoStates.length;
}
}