Skip to content

Commit

Permalink
Custom undo/redo implementation (#97) (#98)
Browse files Browse the repository at this point in the history
* Custom undo/redo implementation (#97)

* Fix after review (#97)

* Fix after review (#97)

* Fix after review (#97)

* Fix after review (#97)

* Auto-format (#97)
  • Loading branch information
alexeyinkin authored Oct 31, 2022
1 parent 117bce9 commit c64d071
Show file tree
Hide file tree
Showing 18 changed files with 723 additions and 30 deletions.
17 changes: 17 additions & 0 deletions lib/src/code_field/actions/redo.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';

import '../code_controller.dart';

class RedoAction extends Action<RedoTextIntent> {
final CodeController controller;

RedoAction({
required this.controller,
});

@override
Object? invoke(RedoTextIntent intent) {
controller.historyController.redo();
return null;
}
}
17 changes: 17 additions & 0 deletions lib/src/code_field/actions/undo.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';

import '../code_controller.dart';

class UndoAction extends Action<UndoTextIntent> {
final CodeController controller;

UndoAction({
required this.controller,
});

@override
Object? invoke(UndoTextIntent intent) {
controller.historyController.undo();
return null;
}
}
32 changes: 27 additions & 5 deletions lib/src/code_field/code_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ import '../code_modifiers/indent_code_modifier.dart';
import '../code_modifiers/tab_code_modifier.dart';
import '../code_theme/code_theme.dart';
import '../code_theme/code_theme_data.dart';
import '../history/code_history_controller.dart';
import '../history/code_history_record.dart';
import '../named_sections/parsers/abstract.dart';
import '../wip/autocomplete/popup_controller.dart';
import 'actions/copy.dart';
import 'actions/redo.dart';
import 'actions/undo.dart';
import 'editor_params.dart';
import 'span_builder.dart';

Expand Down Expand Up @@ -102,9 +106,12 @@ class CodeController extends TextEditingController {
RegExp? styleRegExp;
late PopupController popupController;
final autocompleter = Autocompleter();
late final historyController = CodeHistoryController(codeController: this);

late final actions = {
late final actions = <Type, Action<Intent>>{
CopySelectionTextIntent: CopyAction(controller: this),
RedoTextIntent: RedoAction(controller: this),
UndoTextIntent: UndoAction(controller: this),
};

CodeController({
Expand Down Expand Up @@ -301,7 +308,14 @@ class CodeController extends TextEditingController {

@override
set value(TextEditingValue newValue) {
if (newValue.text != super.value.text) {
final hasTextChanged = newValue.text != super.value.text;
final hasSelectionChanged = newValue.selection != super.value.selection;

if (!hasTextChanged && !hasSelectionChanged) {
return;
}

if (hasTextChanged) {
final loc = _insertedLoc(text, newValue.text);

if (loc != null) {
Expand Down Expand Up @@ -339,9 +353,6 @@ class CodeController extends TextEditingController {
//print('\n\n${_code.text}');
}

bool hasTextChanged = newValue.text != super.value.text;
bool hasSelectionChanged = newValue.selection != super.value.selection;

//Because of this part of code ctrl + z dont't work. But maybe it's important, so please don't delete.
// Now fix the textfield for web
// if (_webSpaceFix) {
Expand All @@ -352,7 +363,9 @@ class CodeController extends TextEditingController {
_webSpaceFix ? _middleDotsToSpaces(newValue.text) : newValue.text,
);

historyController.beforeChanged(_code, newValue.selection);
super.value = newValue;

if (hasTextChanged) {
autocompleter.blacklist = [newValue.wordAtCursor ?? ''];
autocompleter.setText(this, text);
Expand All @@ -362,6 +375,15 @@ class CodeController extends TextEditingController {
}
}

void applyHistoryRecord(CodeHistoryRecord record) {
_code = record.code;

super.value = TextEditingValue(
text: code.visibleText,
selection: record.selection,
);
}

Code get code => _code;

CodeEditResult? _getEditResultNotBreakingReadOnly(TextEditingValue newValue) {
Expand Down
24 changes: 23 additions & 1 deletion lib/src/code_field/code_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,36 @@ final _shortcuts = {
LogicalKeyboardKey.control,
LogicalKeyboardKey.keyX,
): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
LogicalKeySet(
LogicalKeySet(
LogicalKeyboardKey.meta,
LogicalKeyboardKey.keyX,
): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),
LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.delete,
): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard),

// Undo
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.keyZ,
): const UndoTextIntent(SelectionChangedCause.keyboard),
LogicalKeySet(
LogicalKeyboardKey.meta,
LogicalKeyboardKey.keyZ,
): const UndoTextIntent(SelectionChangedCause.keyboard),

// Redo
LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.control,
LogicalKeyboardKey.keyZ,
): const RedoTextIntent(SelectionChangedCause.keyboard),
LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.keyZ,
): const RedoTextIntent(SelectionChangedCause.keyboard),
};

class CodeField extends StatefulWidget {
Expand Down
4 changes: 0 additions & 4 deletions lib/src/code_field/text_editing_value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,6 @@ extension TextEditingValueExtension on TextEditingValue {
return replaced(selection, '');
}

TextEditingValue replacedSelection(String value) {
return replaced(selection, value);
}

TextEditingValue replacedText(String newText) {
if (newText == text) {
return this;
Expand Down
8 changes: 8 additions & 0 deletions lib/src/code_field/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ extension TextSelectionExtension on TextSelection {
bool get isSelectionNormalized {
return baseOffset <= extentOffset;
}

bool hasMovedOneCharacterRight(TextSelection old) {
if (!old.isCollapsed || !isCollapsed) {
return false;
}

return start == old.start + 1;
}
}
137 changes: 137 additions & 0 deletions lib/src/history/code_history_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:meta/meta.dart';

import '../code/code.dart';
import '../code_field/code_controller.dart';
import '../code_field/text_selection.dart';
import 'code_history_record.dart';
import 'limit_stack.dart';

/// A custom undo/redo implementation for [CodeController].
///
/// This is needed because the built-in implementation listens to the
/// visible text changes in [TextEditingController] and sets that on undo/redo.
/// This would delete hidden ranges and folded blocks.
///
/// With this controller, new records are created:
/// - If the line count has changed.
/// - After the [idle] duration if the text has changed since the last record.
/// - On any selection change other than that of inserting a single
/// character, if the text has changed since the last record.
class CodeHistoryController {
final CodeController codeController;
Code lastCode;
TextSelection lastSelection;
int _currentRecordIndex = 0;
bool _isTextChanged = false;
Timer? _debounceTimer;

@visibleForTesting
final stack = LimitStack<CodeHistoryRecord>(maxLength: limit);

static const idle = Duration(seconds: 5);
static const limit = 100;

CodeHistoryController({
required this.codeController,
}) : lastCode = codeController.code,
lastSelection = codeController.value.selection {
_push();
}

void beforeChanged(Code code, TextSelection selection) {
_dropRedoIfNeed();
bool shouldSave = false;

if (_isTextChanged) {
shouldSave = code.lines.lines.length != lastCode.lines.lines.length;
}

if (!shouldSave) {
if (lastCode.text != code.text) {
_isTextChanged = true;
}

if (_isTextChanged) {
final isText1CharLonger = code.text.length == lastCode.text.length + 1;
final isTypingContinuous = isText1CharLonger &&
selection.hasMovedOneCharacterRight(lastSelection);

if (isTypingContinuous) {
_setTimer();
} else {
shouldSave = true;
}
}
}

if (shouldSave) {
_push();
}

lastCode = code;
lastSelection = selection;
}

void _dropRedoIfNeed() {
stack.removeAfter(_currentRecordIndex + 1);
}

void undo() {
if (_isTextChanged) {
_push();
}

if (_currentRecordIndex == 0) {
return;
}

_applyHistoryRecord(stack[--_currentRecordIndex]);
}

void redo() {
if (_currentRecordIndex == stack.length - 1) {
return;
}

_applyHistoryRecord(stack[++_currentRecordIndex]);
}

void _applyHistoryRecord(CodeHistoryRecord record) {
lastCode = record.code;
lastSelection = record.selection;

codeController.applyHistoryRecord(record);
}

void _push() {
_debounceTimer?.cancel();
_pushRecord(_createRecord());
_isTextChanged = false;
}

void _setTimer() {
_debounceTimer?.cancel();
_debounceTimer = Timer(idle, _push);
}

CodeHistoryRecord _createRecord() {
return CodeHistoryRecord(
code: lastCode,
selection: lastSelection,
);
}

void _pushRecord(CodeHistoryRecord record) {
stack.push(record);
_currentRecordIndex = stack.length - 1;
}

void deleteHistory() {
stack.clear();
_push();
_currentRecordIndex = 0;
}
}
20 changes: 20 additions & 0 deletions lib/src/history/code_history_record.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';

import '../code/code.dart';

class CodeHistoryRecord with EquatableMixin {
final Code code;
final TextSelection selection;

const CodeHistoryRecord({
required this.code,
required this.selection,
});

@override
List<Object> get props => [
code,
selection,
];
}
34 changes: 34 additions & 0 deletions lib/src/history/limit_stack.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class LimitStack<T> extends Iterable<T> {
final int maxLength;
final _items = <T>[];

LimitStack({
required this.maxLength,
});

@override
int get length => _items.length;

void push(T value) {
_items.add(value);

if (_items.length > maxLength) {
_items.removeRange(0, _items.length - maxLength);
}
}

void removeAfter(int n) {
_items.removeRange(n, _items.length);
}

void clear() {
_items.clear();
}

T operator [](int n) {
return _items[n];
}

@override
Iterator<T> get iterator => _items.iterator;
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies:
tuple: ^2.0.1

dev_dependencies:
fake_async: ^1.3.1
flutter_test: { sdk: flutter }
total_lints: ^2.18.0

Expand Down
2 changes: 1 addition & 1 deletion test/src/code_field/code_controller_folding_test.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_code_editor/src/code_field/text_editing_value.dart';
import 'package:flutter_test/flutter_test.dart';

import '../common/create_app.dart';
import '../common/text_editing_value.dart';
import '../common/widget_tester.dart';

const _code = '''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:highlight/languages/java.dart';

import '../common/create_app.dart';
import '../common/text_editing_value.dart';

const _text = '''
// [START section2]
Expand Down
Loading

0 comments on commit c64d071

Please sign in to comment.