Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom undo/redo implementation (#97) #98

Merged
merged 6 commits into from
Oct 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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