diff --git a/lib/src/controller/quill_controller.dart b/lib/src/controller/quill_controller.dart index 4ceb788a..35cfdcaf 100644 --- a/lib/src/controller/quill_controller.dart +++ b/lib/src/controller/quill_controller.dart @@ -40,19 +40,25 @@ class QuillController extends ChangeNotifier { _selection = selection; factory QuillController.basic( - {QuillControllerConfigurations configurations = - const QuillControllerConfigurations(), - FocusNode? editorFocusNode}) { - return QuillController( - configurations: configurations, - editorFocusNode: editorFocusNode, - document: Document(), - selection: const TextSelection.collapsed(offset: 0), - ); - } + {QuillControllerConfigurations configurations = + const QuillControllerConfigurations(), + FocusNode? editorFocusNode}) => + QuillController( + configurations: configurations, + editorFocusNode: editorFocusNode, + document: Document(), + selection: const TextSelection.collapsed(offset: 0), + ); final QuillControllerConfigurations configurations; + /// Local copy of editor configurations enables fail-safe setting from editor _initState method + QuillEditorConfigurations? _editorConfigurations; + QuillEditorConfigurations? get editorConfigurations => + configurations.editorConfigurations ?? _editorConfigurations; + set editorConfigurations(QuillEditorConfigurations? value) => + _editorConfigurations = value; + /// Document managed by this controller. Document _document; @@ -403,7 +409,8 @@ class QuillController extends ChangeNotifier { } textSelection = selection.copyWith( - baseOffset: delta.transformPosition(selection.baseOffset, force: false), + baseOffset: + delta.transformPosition(selection.baseOffset, force: false), extentOffset: delta.transformPosition( selection.extentOffset, force: false, @@ -489,9 +496,6 @@ class QuillController extends ChangeNotifier { /// Used to give focus to the editor following a toolbar action FocusNode? editorFocusNode; - /// Used to access embedBuilders for clipboard output - QuillEditorConfigurations? editorConfigurations; - ImageUrl? _copiedImageUrl; ImageUrl? get copiedImageUrl => _copiedImageUrl; @@ -553,14 +557,7 @@ class QuillController extends ChangeNotifier { // See https://github.com/flutter/flutter/issues/11427 final plainTextClipboardData = await Clipboard.getData(Clipboard.kTextPlain); - if (plainTextClipboardData?.text != null) { - - /// Internal copy-paste preserves styles and embeds - if ( plainTextClipboardData!.text == _pastePlainText && _pastePlainText.isNotEmpty && _pasteDelta.isNotEmpty ) { - replaceText(selection.start, selection.end - selection.start, _pasteDelta, TextSelection.collapsed(offset: selection.end)); - } else { - replaceText(selection.start, selection.end - selection.start, plainTextClipboardData.text, TextSelection.collapsed(offset: selection.end + plainTextClipboardData.text!.length)); - } + if (pasteUsingPlainOrDelta(plainTextClipboardData?.text)) { updateEditor?.call(); return true; } @@ -573,6 +570,28 @@ class QuillController extends ChangeNotifier { return false; } + /// Internal method to allow unit testing + bool pasteUsingPlainOrDelta(String? clipboardText) { + if (clipboardText != null) { + /// Internal copy-paste preserves styles and embeds + if (clipboardText == _pastePlainText && + _pastePlainText.isNotEmpty && + _pasteDelta.isNotEmpty) { + replaceText(selection.start, selection.end - selection.start, + _pasteDelta, TextSelection.collapsed(offset: selection.end)); + } else { + replaceText( + selection.start, + selection.end - selection.start, + clipboardText, + TextSelection.collapsed( + offset: selection.end + clipboardText.length)); + } + return true; + } + return false; + } + void _pasteUsingDelta(Delta deltaFromClipboard) { replaceText( selection.start, diff --git a/lib/src/controller/quill_controller_configurations.dart b/lib/src/controller/quill_controller_configurations.dart index 95126178..86ee9bbc 100644 --- a/lib/src/controller/quill_controller_configurations.dart +++ b/lib/src/controller/quill_controller_configurations.dart @@ -1,6 +1,15 @@ +import '../editor/config/editor_configurations.dart'; + class QuillControllerConfigurations { const QuillControllerConfigurations( - {this.onClipboardPaste, this.requireScriptFontFeatures = false}); + {this.editorConfigurations, + this.onClipboardPaste, + this.requireScriptFontFeatures = false}); + + /// Provides central access to editor configurations required for controller actions + /// + /// Future: will be changed to 'required final' + final QuillEditorConfigurations? editorConfigurations; /// Callback when the user pastes and data has not already been processed /// diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index 29df932e..92e63d9a 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -71,8 +71,8 @@ int getPositionDelta(Delta user, Delta actual) { } if (userOperation.key == actualOperation.key) { /// Insertions must update diff allowing for type mismatch of Operation - if ( userOperation.key == Operation.insertKey) { - if ( userOperation.data is Delta && actualOperation.data is String ) { + if (userOperation.key == Operation.insertKey) { + if (userOperation.data is Delta && actualOperation.data is String) { diff += actualOperation.length!; } } diff --git a/test/controller/controller_clipboard_test.dart b/test/controller/controller_clipboard_test.dart new file mode 100644 index 00000000..4ff2e5ce --- /dev/null +++ b/test/controller/controller_clipboard_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/quill_delta.dart'; +import 'package:test/test.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + group('copy', () { + const testDocumentContents = 'data'; + late QuillController controller; + + setUp(() { + controller = QuillController.basic() + ..compose(Delta()..insert(testDocumentContents), + const TextSelection.collapsed(offset: 0), ChangeSource.local); + }); + + test('clipboardSelection empty', () { + expect(controller.clipboardSelection(true), false, + reason: 'No effect when no selection'); + expect(controller.clipboardSelection(false), false); + }); + + test('clipboardSelection', () { + controller + ..replaceText(0, 4, 'bold plain italic', null) + ..formatText(0, 4, Attribute.bold) + ..formatText(11, 17, Attribute.italic) + ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 14), + ChangeSource.local); + // + expect(controller.clipboardSelection(true), true); + expect(controller.document.length, 18, + reason: 'Copy does not change the document'); + expect(controller.clipboardSelection(false), true); + expect(controller.document.length, 6, reason: 'Cut changes the document'); + // + controller + ..readOnly = true + ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 4), + ChangeSource.local); + expect(controller.selection.isCollapsed, false); + expect(controller.clipboardSelection(true), true); + expect(controller.document.length, 6); + expect(controller.clipboardSelection(false), false); + expect(controller.document.length, 6, + reason: 'Cut not permitted on readOnly document'); + }); + }); + + group('paste', () { + test('Plain', () async { + final controller = QuillController.basic() + ..compose(Delta()..insert('[]'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..updateSelection( + const TextSelection.collapsed(offset: 1), ChangeSource.local); + // + expect(controller.document.toPlainText(), '[]\n'); + expect(controller.pasteUsingPlainOrDelta('insert'), true); + expect(controller.document.toPlainText(), '[insert]\n'); + }); + + test('Plain lines', () async { + final controller = QuillController.basic() + ..compose(Delta()..insert('[]'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..updateSelection( + const TextSelection.collapsed(offset: 1), ChangeSource.local); + // + expect(controller.document.toPlainText(), '[]\n'); + expect(controller.pasteUsingPlainOrDelta('1\n2\n3\n'), true); + expect(controller.document.toPlainText(), '[1\n2\n3\n]\n'); + }); + + test('Paste from external', () async { + final source = QuillController.basic() + ..compose(Delta()..insert('Plain text'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..updateSelection(const TextSelection(baseOffset: 4, extentOffset: 8), + ChangeSource.local); + assert(source.clipboardSelection(true)); + expect(source.pastePlainText, 'n te'); + // + final controller = QuillController.basic() + ..compose(Delta()..insert('[]'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..updateSelection( + const TextSelection.collapsed(offset: 1), ChangeSource.local); + // + expect(controller.pasteUsingPlainOrDelta('insert'), true, + reason: 'External paste'); + expect(controller.document.toPlainText(), '[insert]\n'); + }); + + test('Delta simple', () async { + final source = QuillController.basic() + ..compose(Delta()..insert('Plain text'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..formatText(6, 8, Attribute.bold) + ..updateSelection(const TextSelection(baseOffset: 4, extentOffset: 8), + ChangeSource.local); + assert(source.clipboardSelection(true)); + expect(source.pastePlainText, 'n te'); + expect( + source.pasteDelta, + Delta() + ..insert('n ') + ..insert('te', {'bold': true})); + // + final controller = QuillController.basic() + ..compose(Delta()..insert('[]'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..updateSelection( + const TextSelection.collapsed(offset: 1), ChangeSource.local); + // + expect(controller.pasteUsingPlainOrDelta('n te'), true, + reason: 'Internal paste'); + expect(controller.document.toPlainText(), '[n te]\n'); + expect( + controller.document.toDelta(), + Delta() + ..insert('[n ') + ..insert('te', {'bold': true}) + ..insert(']\n')); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + }); + + test('Delta multi line', () async { + const blockAttribute = Attribute.ol; + const plainSelection = 'BC\nDEF\nGHI\nJK'; + final source = QuillController.basic() + ..compose(Delta()..insert('ABC\nDEF\nGHI\nJKL'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..formatText(1, 1, Attribute.underline) // ABC with B underlined + ..formatText(4, 0, blockAttribute) // 1. DEF with E in italic + ..formatText(5, 1, Attribute.italic) + ..formatText(8, 0, blockAttribute) // 2. GHI with H as inline code + ..formatText(9, 1, Attribute.inlineCode) + ..formatText(13, 1, Attribute.strikeThrough) // JKL with K strikethrough + ..updateSelection(const TextSelection(baseOffset: 1, extentOffset: 14), + ChangeSource.local); + // + assert(source.clipboardSelection(true)); + expect(source.pastePlainText, plainSelection); + expect( + source.pasteDelta, + Delta() + ..insert('B', {'underline': true}) + ..insert('C\nD') + ..insert('E', {'italic': true}) + ..insert('F') + ..insert('\n', {'list': 'ordered'}) + ..insert('G') + ..insert('H', {'code': true}) + ..insert('I') + ..insert('\n', {'list': 'ordered'}) + ..insert('J') + ..insert('K', {'strike': true})); + // + final controller = QuillController.basic() + ..compose(Delta()..insert('[]'), + const TextSelection.collapsed(offset: 0), ChangeSource.local) + ..updateSelection( + const TextSelection.collapsed(offset: 1), ChangeSource.local); + // + expect(controller.pasteUsingPlainOrDelta(plainSelection), true, + reason: 'Internal paste'); + expect(controller.document.toPlainText(), '[$plainSelection]\n'); + expect( + controller.document.toDelta(), + Delta() + ..insert('[') + ..insert('B', {'underline': true}) + ..insert('C\nD') + ..insert('E', {'italic': true}) + ..insert('F') + ..insert('\n', {'list': 'ordered'}) + ..insert('G') + ..insert('H', {'code': true}) + ..insert('I') + ..insert('\n', {'list': 'ordered'}) + ..insert('J') + ..insert('K', {'strike': true}) + ..insert(']\n')); + expect(controller.selection, const TextSelection.collapsed(offset: 14)); + }); + }); +} diff --git a/test/controller/controller_test.dart b/test/controller/controller_test.dart index 7c04b274..0bf4c020 100644 --- a/test/controller/controller_test.dart +++ b/test/controller/controller_test.dart @@ -324,38 +324,6 @@ void main() { Delta()..insert('test $originalContents')); }); - test('clipboardSelection empty', () { - expect(controller.clipboardSelection(true), false, - reason: 'No effect when no selection'); - expect(controller.clipboardSelection(false), false); - }); - - test('clipboardSelection', () { - controller - ..replaceText(0, 4, 'bold plain italic', null) - ..formatText(0, 4, Attribute.bold) - ..formatText(11, 17, Attribute.italic) - ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 14), - ChangeSource.local); - // - expect(controller.clipboardSelection(true), true); - expect(controller.document.length, 18, - reason: 'Copy does not change the document'); - expect(controller.clipboardSelection(false), true); - expect(controller.document.length, 6, reason: 'Cut changes the document'); - // - controller - ..readOnly = true - ..updateSelection(const TextSelection(baseOffset: 2, extentOffset: 4), - ChangeSource.local); - expect(controller.selection.isCollapsed, false); - expect(controller.clipboardSelection(true), true); - expect(controller.document.length, 6); - expect(controller.clipboardSelection(false), false); - expect(controller.document.length, 6, - reason: 'Cut not permitted on readOnly document'); - }); - test('blockSelectionStyles', () { Style select(int start, int end) { controller.updateSelection(