diff --git a/lib/src/controller/quill_controller.dart b/lib/src/controller/quill_controller.dart index 4dd3e7bf..5f85b623 100644 --- a/lib/src/controller/quill_controller.dart +++ b/lib/src/controller/quill_controller.dart @@ -17,6 +17,7 @@ import '../document/nodes/embeddable.dart'; import '../document/nodes/leaf.dart'; import '../document/structs/doc_change.dart'; import '../document/style.dart'; +import '../editor/config/editor_configurations.dart'; import '../editor_toolbar_controller_shared/clipboard/clipboard_service_provider.dart'; import 'quill_controller_configurations.dart'; @@ -39,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; @@ -476,10 +483,13 @@ class QuillController extends ChangeNotifier { /// Clipboard caches last copy to allow paste with styles. Static to allow paste between multiple instances of editor. static String _pastePlainText = ''; + static Delta _pasteDelta = Delta(); static List _pasteStyleAndEmbed = []; String get pastePlainText => _pastePlainText; + Delta get pasteDelta => _pasteDelta; List get pasteStyleAndEmbed => _pasteStyleAndEmbed; + bool readOnly; /// Used to give focus to the editor following a toolbar action @@ -495,9 +505,17 @@ class QuillController extends ChangeNotifier { bool clipboardSelection(bool copy) { copiedImageUrl = null; - _pastePlainText = getPlainText(); + + /// Get the text for the selected region and expand the content of Embedded objects. + _pastePlainText = document.getPlainText( + selection.start, selection.end - selection.start, editorConfigurations); + + /// Get the internal representation so it can be pasted into a QuillEditor with style retained. _pasteStyleAndEmbed = getAllIndividualSelectionStylesAndEmbed(); + /// Get the deltas for the selection so they can be pasted into a QuillEditor with styles and embeds retained. + _pasteDelta = document.toDelta().slice(selection.start, selection.end); + if (!selection.isCollapsed) { Clipboard.setData(ClipboardData(text: _pastePlainText)); if (!copy) { @@ -538,28 +556,7 @@ class QuillController extends ChangeNotifier { // See https://github.com/flutter/flutter/issues/11427 final plainTextClipboardData = await Clipboard.getData(Clipboard.kTextPlain); - if (plainTextClipboardData != null) { - final lines = plainTextClipboardData.text!.split('\n'); - for (var i = 0; i < lines.length; ++i) { - final line = lines[i]; - if (line.isNotEmpty) { - replaceTextWithEmbeds( - selection.start, - selection.end - selection.start, - line, - TextSelection.collapsed(offset: selection.start + line.length), - ); - } - if (i != lines.length - 1) { - document.insert(selection.extentOffset, '\n'); - _updateSelection( - TextSelection.collapsed( - offset: selection.extentOffset + 1, - ), - insertNewline: true, - ); - } - } + if (pasteUsingPlainOrDelta(plainTextClipboardData?.text)) { updateEditor?.call(); return true; } @@ -572,6 +569,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 82dccf5a..92e63d9a 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -70,19 +70,18 @@ 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) { + diff += actualOperation.length!; + } + } continue; } else if (userOperation.isInsert && actualOperation.isRetain) { diff -= userOperation.length!; } else if (userOperation.isDelete && actualOperation.isRetain) { diff += userOperation.length!; } else if (userOperation.isRetain && actualOperation.isInsert) { - String? operationTxt = ''; - if (actualOperation.data is String) { - operationTxt = actualOperation.data as String?; - } - if (operationTxt!.startsWith('\n')) { - continue; - } diff += actualOperation.length!; } } diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index e1f75575..d001551e 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -6,6 +6,7 @@ import '../../quill_delta.dart'; import '../common/structs/offset_value.dart'; import '../common/structs/segment_leaf_node.dart'; import '../delta/delta_x.dart'; +import '../editor/config/editor_configurations.dart'; import '../editor/embed/embed_editor_builder.dart'; import '../rules/rule.dart'; import 'attribute.dart'; @@ -239,9 +240,9 @@ class Document { } /// Returns plain text within the specified text range. - String getPlainText(int index, int len) { + String getPlainText(int index, int len, [QuillEditorConfigurations? config]) { final res = queryChild(index); - return (res.node as Line).getPlainText(res.offset, len); + return (res.node as Line).getPlainText(res.offset, len, config); } /// Returns [Line] located at specified character [offset]. diff --git a/lib/src/document/nodes/line.dart b/lib/src/document/nodes/line.dart index 71119347..fe24700e 100644 --- a/lib/src/document/nodes/line.dart +++ b/lib/src/document/nodes/line.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import '../../../../quill_delta.dart'; import '../../common/structs/offset_value.dart'; +import '../../editor/config/editor_configurations.dart'; import '../../editor/embed/embed_editor_builder.dart'; import '../../editor_toolbar_controller_shared/copy_cut_service/copy_cut_service_provider.dart'; import '../attribute.dart'; @@ -512,14 +513,17 @@ base class Line extends QuillContainer { } /// Returns plain text within the specified text range. - String getPlainText(int offset, int len) { + String getPlainText(int offset, int len, + [QuillEditorConfigurations? config]) { final plainText = StringBuffer(); - _getPlainText(offset, len, plainText); + _getPlainText(offset, len, plainText, config); return plainText.toString(); } - int _getNodeText(Leaf node, StringBuffer buffer, int offset, int remaining) { - final text = node.toPlainText(); + int _getNodeText(Leaf node, StringBuffer buffer, int offset, int remaining, + QuillEditorConfigurations? config) { + final text = + node.toPlainText(config?.embedBuilders, config?.unknownEmbedBuilder); if (text == Embed.kObjectReplacementCharacter) { final embed = node.value as Embeddable; final provider = CopyCutServiceProvider.instance; @@ -539,12 +543,19 @@ base class Line extends QuillContainer { return remaining - node.length; } + /// Text for clipboard will expand the content of Embed nodes + if (node is Embed && config != null) { + buffer.write(text); + return remaining - 1; + } + final end = math.min(offset + remaining, text.length); buffer.write(text.substring(offset, end)); return remaining - (end - offset); } - int _getPlainText(int offset, int len, StringBuffer plainText) { + int _getPlainText(int offset, int len, StringBuffer plainText, + QuillEditorConfigurations? config) { var len0 = len; final data = queryChild(offset, false); var node = data.node as Leaf?; @@ -555,11 +566,12 @@ base class Line extends QuillContainer { plainText.write('\n'); len0 -= 1; } else { - len0 = _getNodeText(node, plainText, offset - node.offset, len0); + len0 = + _getNodeText(node, plainText, offset - node.offset, len0, config); while (!node!.isLast && len0 > 0) { node = node.next as Leaf; - len0 = _getNodeText(node, plainText, 0, len0); + len0 = _getNodeText(node, plainText, 0, len0, config); } if (len0 > 0) { @@ -570,7 +582,7 @@ base class Line extends QuillContainer { } if (len0 > 0 && nextLine != null) { - len0 = nextLine!._getPlainText(0, len0, plainText); + len0 = nextLine!._getPlainText(0, len0, plainText, config); } } diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 57a6cfa8..9681c515 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -174,7 +174,6 @@ class QuillEditorState extends State @override void initState() { super.initState(); - _editorKey = configurations.editorKey ?? GlobalKey(); _selectionGestureDetectorBuilder = _QuillEditorSelectionGestureDetectorBuilder( @@ -182,9 +181,11 @@ class QuillEditorState extends State configurations.detectWordBoundary, ); + widget.configurations.controller.editorConfigurations ??= + widget.configurations; + final focusNode = - widget.configurations.controller.editorFocusNode ?? widget.focusNode; - widget.configurations.controller.editorFocusNode = focusNode; + widget.configurations.controller.editorFocusNode ??= widget.focusNode; if (configurations.autoFocus) { focusNode.requestFocus(); 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(