Fix Multiline paste with attributes and embeds (#2074)

pull/2082/head v10.1.2
AtlasAutocode 8 months ago committed by GitHub
parent 1c3ccf7a9a
commit 6f8fa24470
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 85
      lib/src/controller/quill_controller.dart
  2. 11
      lib/src/controller/quill_controller_configurations.dart
  3. 13
      lib/src/delta/delta_diff.dart
  4. 5
      lib/src/document/document.dart
  5. 28
      lib/src/document/nodes/line.dart
  6. 7
      lib/src/editor/editor.dart
  7. 190
      test/controller/controller_clipboard_test.dart
  8. 32
      test/controller/controller_test.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<OffsetValue> _pasteStyleAndEmbed = <OffsetValue>[];
String get pastePlainText => _pastePlainText;
Delta get pasteDelta => _pasteDelta;
List<OffsetValue> 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,

@ -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
///

@ -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!;
}
}

@ -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].

@ -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<Leaf?> {
}
/// 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<Leaf?> {
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<Leaf?> {
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<Leaf?> {
}
if (len0 > 0 && nextLine != null) {
len0 = nextLine!._getPlainText(0, len0, plainText);
len0 = nextLine!._getPlainText(0, len0, plainText, config);
}
}

@ -174,7 +174,6 @@ class QuillEditorState extends State<QuillEditor>
@override
void initState() {
super.initState();
_editorKey = configurations.editorKey ?? GlobalKey<EditorState>();
_selectionGestureDetectorBuilder =
_QuillEditorSelectionGestureDetectorBuilder(
@ -182,9 +181,11 @@ class QuillEditorState extends State<QuillEditor>
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();

@ -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));
});
});
}

@ -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(

Loading…
Cancel
Save