diff --git a/example/lib/screens/quill/quill_screen.dart b/example/lib/screens/quill/quill_screen.dart index 29aec158..ec97b5d8 100644 --- a/example/lib/screens/quill/quill_screen.dart +++ b/example/lib/screens/quill/quill_screen.dart @@ -59,6 +59,7 @@ class _QuillScreenState extends State { @override Widget build(BuildContext context) { + _controller.readOnly = _isReadOnly; return Scaffold( appBar: AppBar( title: const Text('Flutter Quill'), @@ -152,7 +153,6 @@ class _QuillScreenState extends State { configurations: QuillEditorConfigurations( sharedConfigurations: _sharedConfigurations, controller: _controller, - readOnly: _isReadOnly, ), scrollController: _editorScrollController, focusNode: _editorFocusNode, diff --git a/lib/src/l10n/generated/quill_localizations.dart b/lib/src/l10n/generated/quill_localizations.dart index 62c9b8f4..ab25d266 100644 --- a/lib/src/l10n/generated/quill_localizations.dart +++ b/lib/src/l10n/generated/quill_localizations.dart @@ -211,6 +211,18 @@ abstract class FlutterQuillLocalizations { /// **'Copy'** String get copy; + /// No description provided for @cut. + /// + /// In en, this message translates to: + /// **'Cut'** + String get cut => 'Cut'; + + /// No description provided for @paste. + /// + /// In en, this message translates to: + /// **'Paste'** + String get paste => 'Paste'; + /// No description provided for @remove. /// /// In en, this message translates to: diff --git a/lib/src/l10n/generated/quill_localizations_da.dart b/lib/src/l10n/generated/quill_localizations_da.dart index 80b0de6a..ca0def95 100644 --- a/lib/src/l10n/generated/quill_localizations_da.dart +++ b/lib/src/l10n/generated/quill_localizations_da.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsDa extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_en.dart b/lib/src/l10n/generated/quill_localizations_en.dart index 9fb6e2e1..d2bbb75d 100644 --- a/lib/src/l10n/generated/quill_localizations_en.dart +++ b/lib/src/l10n/generated/quill_localizations_en.dart @@ -25,6 +25,12 @@ class FlutterQuillLocalizationsEn extends FlutterQuillLocalizations { @override String get copy => 'Copy'; + @override + String get cut => 'Cut'; + + @override + String get paste => 'Paste'; + @override String get remove => 'Remove'; @@ -128,7 +134,7 @@ class FlutterQuillLocalizationsEn extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; @@ -402,7 +408,7 @@ class FlutterQuillLocalizationsEnUs extends FlutterQuillLocalizationsEn { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_ko.dart b/lib/src/l10n/generated/quill_localizations_ko.dart index 237c0b61..5a0633fd 100644 --- a/lib/src/l10n/generated/quill_localizations_ko.dart +++ b/lib/src/l10n/generated/quill_localizations_ko.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsKo extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_ms.dart b/lib/src/l10n/generated/quill_localizations_ms.dart index faf2851d..ee33ad1c 100644 --- a/lib/src/l10n/generated/quill_localizations_ms.dart +++ b/lib/src/l10n/generated/quill_localizations_ms.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsMs extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_nl.dart b/lib/src/l10n/generated/quill_localizations_nl.dart index 66788997..faedf0dc 100644 --- a/lib/src/l10n/generated/quill_localizations_nl.dart +++ b/lib/src/l10n/generated/quill_localizations_nl.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsNl extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_pl.dart b/lib/src/l10n/generated/quill_localizations_pl.dart index 1048c567..b857ce99 100644 --- a/lib/src/l10n/generated/quill_localizations_pl.dart +++ b/lib/src/l10n/generated/quill_localizations_pl.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsPl extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_pt.dart b/lib/src/l10n/generated/quill_localizations_pt.dart index 6e55805f..425e2323 100644 --- a/lib/src/l10n/generated/quill_localizations_pt.dart +++ b/lib/src/l10n/generated/quill_localizations_pt.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsPt extends FlutterQuillLocalizations { String get alignRight => 'Align right'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Text direction'; diff --git a/lib/src/l10n/generated/quill_localizations_tk.dart b/lib/src/l10n/generated/quill_localizations_tk.dart index b5096044..70c9a940 100644 --- a/lib/src/l10n/generated/quill_localizations_tk.dart +++ b/lib/src/l10n/generated/quill_localizations_tk.dart @@ -128,7 +128,7 @@ class FlutterQuillLocalizationsTk extends FlutterQuillLocalizations { String get alignRight => 'Saga deňleşdir'; @override - String get justifyWinWidth => 'Justify win width'; + String get justifyWinWidth => 'Justify'; @override String get textDirection => 'Tekst ugry'; diff --git a/lib/src/models/config/editor/editor_configurations.dart b/lib/src/models/config/editor/editor_configurations.dart index d3f72b0c..92c71fbb 100644 --- a/lib/src/models/config/editor/editor_configurations.dart +++ b/lib/src/models/config/editor/editor_configurations.dart @@ -32,7 +32,6 @@ class QuillEditorConfigurations extends Equatable { this.autoFocus = false, this.expands = false, this.placeholder, - this.readOnly = false, this.checkBoxReadOnly, this.disableClipboard = false, this.textSelectionThemeData, @@ -96,7 +95,7 @@ class QuillEditorConfigurations extends Equatable { /// by any shortcut or keyboard operation. The text is still selectable. /// /// Defaults to `false`. Must not be `null`. - final bool readOnly; + bool get readOnly => controller.readOnly; /// Override [readOnly] for checkbox. /// @@ -142,11 +141,11 @@ class QuillEditorConfigurations extends Equatable { /// Whether the [onTapOutside] should be triggered or not /// Defaults to `true` - /// it have default implementation, check [onTapOuside] for more + /// it have default implementation, check [onTapOutside] for more final bool isOnTapOutsideEnabled; /// This will run only when [isOnTapOutsideEnabled] is true - /// by default on desktop and web it will unfocus + /// by default on desktop and web it will un-focus /// on mobile it will only unFocus if the kind property of /// event [PointerDownEvent] is [PointerDeviceKind.unknown] /// you can override this to fit your needs @@ -313,7 +312,7 @@ class QuillEditorConfigurations extends Equatable { /// Additional list if links prefixes, which must not be prepended /// with "https://" when [LinkMenuAction.launch] happened /// - /// Useful for deeplinks + /// Useful for deep-links final List customLinkPrefixes; /// Configures the dialog theme. @@ -367,10 +366,10 @@ class QuillEditorConfigurations extends Equatable { @override List get props => [ placeholder, - readOnly, + controller.readOnly, ]; - // We might use code generator like freezed but sometimes it can be limitied + // We might use code generator like freezed but sometimes it can be limited // instead whatever there is a change to the parameters in this class please // regenerate this function using extension in vs code or plugin in intellij @@ -431,7 +430,6 @@ class QuillEditorConfigurations extends Equatable { sharedConfigurations: sharedConfigurations ?? this.sharedConfigurations, controller: controller ?? this.controller, placeholder: placeholder ?? this.placeholder, - readOnly: readOnly ?? this.readOnly, checkBoxReadOnly: checkBoxReadOnly ?? this.checkBoxReadOnly, disableClipboard: disableClipboard ?? this.disableClipboard, scrollable: scrollable ?? this.scrollable, diff --git a/lib/src/models/config/quill_configurations.dart b/lib/src/models/config/quill_configurations.dart index 853136b7..a07ecf1e 100644 --- a/lib/src/models/config/quill_configurations.dart +++ b/lib/src/models/config/quill_configurations.dart @@ -1,3 +1,4 @@ export 'editor/editor_configurations.dart'; +export 'quill_controller_configurations.dart'; export 'quill_shared_configurations.dart'; export 'toolbar/simple_toolbar_configurations.dart'; diff --git a/lib/src/models/config/quill_controller_configurations.dart b/lib/src/models/config/quill_controller_configurations.dart new file mode 100644 index 00000000..ae38841a --- /dev/null +++ b/lib/src/models/config/quill_controller_configurations.dart @@ -0,0 +1,8 @@ +class QuillControllerConfigurations { + const QuillControllerConfigurations({this.onClipboardPaste}); + + /// Callback when the user pastes and data has not already been processed + /// + /// Return true if the paste operation was handled + final Future Function()? onClipboardPaste; +} diff --git a/lib/src/models/config/toolbar/simple_toolbar_button_options.dart b/lib/src/models/config/toolbar/simple_toolbar_button_options.dart index 8108f86d..3e787f12 100644 --- a/lib/src/models/config/toolbar/simple_toolbar_button_options.dart +++ b/lib/src/models/config/toolbar/simple_toolbar_button_options.dart @@ -75,6 +75,9 @@ class QuillSimpleToolbarButtonOptions extends Equatable { this.linkStyle = const QuillToolbarLinkStyleButtonOptions(), this.linkStyle2 = const QuillToolbarLinkStyleButton2Options(), this.customButtons = const QuillToolbarCustomButtonOptions(), + this.clipboardCut = const QuillToolbarToggleStyleButtonOptions(), + this.clipboardCopy = const QuillToolbarToggleStyleButtonOptions(), + this.clipboardPaste = const QuillToolbarToggleStyleButtonOptions(), }); /// The base configurations for all the buttons which will apply to all @@ -113,6 +116,10 @@ class QuillSimpleToolbarButtonOptions extends Equatable { final QuillToolbarSearchButtonOptions search; + final QuillToolbarToggleStyleButtonOptions clipboardCut; + final QuillToolbarToggleStyleButtonOptions clipboardCopy; + final QuillToolbarToggleStyleButtonOptions clipboardPaste; + /// The reason we call this buttons in the end because this is responsible /// for all the header style buttons and not just one, you still /// can customize it and you also have child builder diff --git a/lib/src/models/config/toolbar/simple_toolbar_configurations.dart b/lib/src/models/config/toolbar/simple_toolbar_configurations.dart index 5b86ad52..89175980 100644 --- a/lib/src/models/config/toolbar/simple_toolbar_configurations.dart +++ b/lib/src/models/config/toolbar/simple_toolbar_configurations.dart @@ -107,6 +107,9 @@ class QuillSimpleToolbarConfigurations extends QuillSharedToolbarProperties { this.showSearchButton = true, this.showSubscript = true, this.showSuperscript = true, + this.showClipboardCut = true, + this.showClipboardCopy = true, + this.showClipboardPaste = true, this.linkStyleType = LinkStyleType.original, this.headerStyleType = HeaderStyleType.original, @@ -195,6 +198,9 @@ class QuillSimpleToolbarConfigurations extends QuillSharedToolbarProperties { final bool showSearchButton; final bool showSubscript; final bool showSuperscript; + final bool showClipboardCut; + final bool showClipboardCopy; + final bool showClipboardPaste; /// Toolbar items to display for controls of embed blocks final List? embedButtons; diff --git a/lib/src/models/documents/nodes/line.dart b/lib/src/models/documents/nodes/line.dart index 86f2b499..be3eb7b0 100644 --- a/lib/src/models/documents/nodes/line.dart +++ b/lib/src/models/documents/nodes/line.dart @@ -407,20 +407,20 @@ base class Line extends QuillContainer { final data = queryChild(offset, true); var node = data.node as Leaf?; if (node != null) { - var pos = 0; - pos = node.length - data.offset; + var pos = math.min(local, node.length - data.offset); if (node is QuillText && node.style.isNotEmpty) { - result.add(OffsetValue(beg, node.style, node.length)); + result.add(OffsetValue(beg, node.style, pos)); } else if (node.value is Embeddable) { - result.add(OffsetValue(beg, node.value as Embeddable, node.length)); + result.add(OffsetValue(beg, node.value as Embeddable, pos)); } + while (!node!.isLast && pos < local) { node = node.next as Leaf; + final span = math.min(local - pos, node.length); if (node is QuillText && node.style.isNotEmpty) { - result.add(OffsetValue(pos + beg, node.style, node.length)); + result.add(OffsetValue(pos + beg, node.style, span)); } else if (node.value is Embeddable) { - result.add( - OffsetValue(pos + beg, node.value as Embeddable, node.length)); + result.add(OffsetValue(pos + beg, node.value as Embeddable, span)); } pos += node.length; } diff --git a/lib/src/widgets/quill/quill_controller.dart b/lib/src/widgets/quill/quill_controller.dart index 1a2634e4..c8e6a7c3 100644 --- a/lib/src/widgets/quill/quill_controller.dart +++ b/lib/src/widgets/quill/quill_controller.dart @@ -2,17 +2,13 @@ import 'dart:math' as math; import 'package:flutter/services.dart' show ClipboardData, Clipboard; import 'package:flutter/widgets.dart'; +import 'package:html/parser.dart' as html_parser; import 'package:meta/meta.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import '../../../flutter_quill.dart'; import '../../../quill_delta.dart'; -import '../../models/documents/attribute.dart'; -import '../../models/documents/document.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/documents/nodes/leaf.dart'; -import '../../models/documents/style.dart'; -import '../../models/structs/doc_change.dart'; -import '../../models/structs/image_url.dart'; -import '../../models/structs/offset_value.dart'; +import '../../models/documents/delta_x.dart'; import '../../utils/delta.dart'; typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); @@ -22,21 +18,28 @@ class QuillController extends ChangeNotifier { QuillController({ required Document document, required TextSelection selection, + this.configurations = const QuillControllerConfigurations(), this.keepStyleOnNewLine = true, this.onReplaceText, this.onDelete, this.onSelectionCompleted, this.onSelectionChanged, + this.readOnly = false, }) : _document = document, _selection = selection; - factory QuillController.basic() { + factory QuillController.basic( + {QuillControllerConfigurations configurations = + const QuillControllerConfigurations()}) { return QuillController( + configurations: configurations, document: Document(), selection: const TextSelection.collapsed(offset: 0), ); } + final QuillControllerConfigurations configurations; + /// Document managed by this controller. Document _document; @@ -471,9 +474,18 @@ class QuillController extends ChangeNotifier { return document.querySegmentLeafNode(offset).leaf; } - /// Clipboard for image url and its corresponding style - ImageUrl? _copiedImageUrl; + // Notify toolbar buttons directly with attributes + Map toolbarButtonToggler = const {}; + + /// Clipboard caches last copy to allow paste with styles. Static to allow paste between multiple instances of editor. + static String _pastePlainText = ''; + static List _pasteStyleAndEmbed = []; + + String get pastePlainText => _pastePlainText; + List get pasteStyleAndEmbed => _pasteStyleAndEmbed; + bool readOnly; + ImageUrl? _copiedImageUrl; ImageUrl? get copiedImageUrl => _copiedImageUrl; set copiedImageUrl(ImageUrl? value) { @@ -481,6 +493,138 @@ class QuillController extends ChangeNotifier { Clipboard.setData(const ClipboardData(text: '')); } - // Notify toolbar buttons directly with attributes - Map toolbarButtonToggler = const {}; + bool clipboardSelection(bool copy) { + copiedImageUrl = null; + _pastePlainText = getPlainText(); + _pasteStyleAndEmbed = getAllIndividualSelectionStylesAndEmbed(); + + if (!selection.isCollapsed) { + Clipboard.setData(ClipboardData(text: _pastePlainText)); + if (!copy) { + if (readOnly) return false; + final sel = selection; + replaceText(sel.start, sel.end - sel.start, '', + TextSelection.collapsed(offset: sel.start)); + } + return true; + } + return false; + } + + /// Returns whether paste operation was handled here. + /// updateEditor is called if paste operation was successful. + Future clipboardPaste({void Function()? updateEditor}) async { + if (readOnly || !selection.isValid) return true; + + if (await _pasteHTML()) { + updateEditor?.call(); + return true; + } + + // Snapshot the input before using `await`. + // See https://github.com/flutter/flutter/issues/11427 + final plainText = await Clipboard.getData(Clipboard.kTextPlain); + if (plainText != null) { + replaceTextWithEmbeds( + selection.start, + selection.end - selection.start, + plainText.text!, + TextSelection.collapsed( + offset: selection.start + plainText.text!.length), + ); + updateEditor?.call(); + return true; + } + + if (await configurations.onClipboardPaste?.call() == true) { + updateEditor?.call(); + return true; + } + + return false; + } + + Future _pasteHTML() async { + final clipboard = SystemClipboard.instance; + if (clipboard != null) { + final reader = await clipboard.read(); + if (reader.canProvide(Formats.htmlText)) { + final html = await reader.readValue(Formats.htmlText); + if (html == null) { + return false; + } + final htmlBody = html_parser.parse(html).body?.outerHtml; + final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? html); + + replaceText( + selection.start, + selection.end - selection.start, + deltaFromClipboard, + TextSelection.collapsed(offset: selection.end), + ); + + return true; + } + } + return false; + } + + void replaceTextWithEmbeds( + int index, + int len, + String insertedText, + TextSelection? textSelection, { + bool ignoreFocus = false, + bool shouldNotifyListeners = true, + }) { + final containsEmbed = + insertedText.codeUnits.contains(Embed.kObjectReplacementInt); + insertedText = + containsEmbed ? _adjustInsertedText(insertedText) : insertedText; + + replaceText(index, len, insertedText, textSelection, + ignoreFocus: ignoreFocus, shouldNotifyListeners: shouldNotifyListeners); + + _applyPasteStyleAndEmbed(insertedText, index, containsEmbed); + } + + void _applyPasteStyleAndEmbed( + String insertedText, int start, bool containsEmbed) { + if (insertedText == pastePlainText && pastePlainText != '' || + containsEmbed) { + final pos = start; + for (final p in pasteStyleAndEmbed) { + final offset = p.offset; + final styleAndEmbed = p.value; + + final local = pos + offset; + if (styleAndEmbed is Embeddable) { + replaceText(local, 0, styleAndEmbed, null); + } else { + final style = styleAndEmbed as Style; + if (style.isInline) { + formatTextStyle(local, p.length!, style); + } else if (style.isBlock) { + final node = document.queryChild(local).node; + if (node != null && p.length == node.length - 1) { + for (final attribute in style.values) { + document.format(local, 0, attribute); + } + } + } + } + } + } + } + + String _adjustInsertedText(String text) { + final sb = StringBuffer(); + for (var i = 0; i < text.length; i++) { + if (text.codeUnitAt(i) == Embed.kObjectReplacementInt) { + continue; + } + sb.write(text[i]); + } + return sb.toString(); + } } diff --git a/lib/src/widgets/raw_editor/raw_editor_state.dart b/lib/src/widgets/raw_editor/raw_editor_state.dart index 957e6186..e452f4fd 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state.dart @@ -13,17 +13,15 @@ import 'package:flutter/services.dart' Clipboard, ClipboardData, HardwareKeyboard, - LogicalKeyboardKey, KeyDownEvent, + LogicalKeyboardKey, SystemChannels, TextInputControl; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart' show KeyboardVisibilityController; -import 'package:html/parser.dart' as html_parser; import 'package:super_clipboard/super_clipboard.dart'; import '../../models/documents/attribute.dart'; -import '../../models/documents/delta_x.dart'; import '../../models/documents/document.dart'; import '../../models/documents/nodes/block.dart'; import '../../models/documents/nodes/embeddable.dart'; @@ -92,12 +90,10 @@ class QuillRawEditorState extends EditorState // for pasting style @override - List get pasteStyleAndEmbed => _pasteStyleAndEmbed; - List _pasteStyleAndEmbed = []; + List get pasteStyleAndEmbed => controller.pasteStyleAndEmbed; @override - String get pastePlainText => _pastePlainText; - String _pastePlainText = ''; + String get pastePlainText => controller.pastePlainText; ClipboardStatusNotifier? _clipboardStatus; final LayerLink _toolbarLayerLink = LayerLink(); @@ -122,16 +118,7 @@ class QuillRawEditorState extends EditorState /// Copy current selection to [Clipboard]. @override void copySelection(SelectionChangedCause cause) { - controller.copiedImageUrl = null; - _pastePlainText = controller.getPlainText(); - _pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed(); - - final selection = textEditingValue.selection; - final text = textEditingValue.text; - if (selection.isCollapsed) { - return; - } - Clipboard.setData(ClipboardData(text: selection.textInside(text))); + if (!controller.clipboardSelection(true)) return; if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); @@ -152,20 +139,7 @@ class QuillRawEditorState extends EditorState /// Cut current selection to [Clipboard]. @override void cutSelection(SelectionChangedCause cause) { - controller.copiedImageUrl = null; - _pastePlainText = controller.getPlainText(); - _pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed(); - - if (widget.configurations.readOnly) { - return; - } - final selection = textEditingValue.selection; - final text = textEditingValue.text; - if (selection.isCollapsed) { - return; - } - Clipboard.setData(ClipboardData(text: selection.textInside(text))); - _replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause)); + if (!controller.clipboardSelection(false)) return; if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); @@ -176,7 +150,7 @@ class QuillRawEditorState extends EditorState /// Paste text from [Clipboard]. @override Future pasteText(SelectionChangedCause cause) async { - if (widget.configurations.readOnly) { + if (controller.readOnly) { return; } @@ -205,76 +179,13 @@ class QuillRawEditorState extends EditorState return; } - final selection = textEditingValue.selection; - if (!selection.isValid) { + if (await controller.clipboardPaste()) { + bringIntoView(textEditingValue.selection.extent); return; } final clipboard = SystemClipboard.instance; - if (clipboard != null) { - final reader = await clipboard.read(); - if (reader.canProvide(Formats.htmlText)) { - final html = await reader.readValue(Formats.htmlText); - if (html == null) { - return; - } - final htmlBody = html_parser.parse(html).body?.outerHtml; - final deltaFromClipboard = DeltaX.fromHtml(htmlBody ?? html); - - controller.replaceText( - textEditingValue.selection.start, - textEditingValue.selection.end - textEditingValue.selection.start, - deltaFromClipboard, - TextSelection.collapsed(offset: textEditingValue.selection.end), - ); - - bringIntoView(textEditingValue.selection.extent); - - // Collapse the selection and hide the toolbar and handles. - userUpdateTextEditingValue( - TextEditingValue( - text: textEditingValue.text, - selection: TextSelection.collapsed( - offset: textEditingValue.selection.end, - ), - ), - cause, - ); - - return; - } - } - - // Snapshot the input before using `await`. - // See https://github.com/flutter/flutter/issues/11427 - final plainText = await Clipboard.getData(Clipboard.kTextPlain); - if (plainText != null) { - _replaceText( - ReplaceTextIntent( - textEditingValue, - plainText.text!, - selection, - cause, - ), - ); - - bringIntoView(textEditingValue.selection.extent); - - // Collapse the selection and hide the toolbar and handles. - userUpdateTextEditingValue( - TextEditingValue( - text: textEditingValue.text, - selection: TextSelection.collapsed( - offset: textEditingValue.selection.end, - ), - ), - cause, - ); - - return; - } - final onImagePaste = widget.configurations.onImagePaste; if (onImagePaste != null) { if (clipboard != null) { @@ -322,7 +233,6 @@ class QuillRawEditorState extends EditorState } } } - return; } /// Select the entire text value. diff --git a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart index 2b5bfa10..3956a0ac 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -4,9 +4,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import '../../models/documents/document.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/documents/nodes/leaf.dart'; -import '../../models/documents/style.dart'; import '../../utils/delta.dart'; import 'raw_editor.dart'; @@ -29,62 +26,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState return; } - var insertedText = diff.inserted; - final containsEmbed = - insertedText.codeUnits.contains(Embed.kObjectReplacementInt); - insertedText = - containsEmbed ? _adjustInsertedText(diff.inserted) : diff.inserted; - - widget.configurations.controller.replaceText( - diff.start, diff.deleted.length, insertedText, value.selection); - - _applyPasteStyleAndEmbed(insertedText, diff.start, containsEmbed); - } - - void _applyPasteStyleAndEmbed( - String insertedText, int start, bool containsEmbed) { - if (insertedText == pastePlainText && pastePlainText != '' || - containsEmbed) { - final pos = start; - for (var i = 0; i < pasteStyleAndEmbed.length; i++) { - final offset = pasteStyleAndEmbed[i].offset; - final styleAndEmbed = pasteStyleAndEmbed[i].value; - - final local = pos + offset; - if (styleAndEmbed is Embeddable) { - widget.configurations.controller - .replaceText(local, 0, styleAndEmbed, null); - } else { - final style = styleAndEmbed as Style; - if (style.isInline) { - widget.configurations.controller - .formatTextStyle(local, pasteStyleAndEmbed[i].length!, style); - } else if (style.isBlock) { - final node = widget.configurations.controller.document - .queryChild(local) - .node; - if (node != null && - pasteStyleAndEmbed[i].length == node.length - 1) { - for (final attribute in style.values) { - widget.configurations.controller.document - .format(local, 0, attribute); - } - } - } - } - } - } - } - - String _adjustInsertedText(String text) { - final sb = StringBuffer(); - for (var i = 0; i < text.length; i++) { - if (text.codeUnitAt(i) == Embed.kObjectReplacementInt) { - continue; - } - sb.write(text[i]); - } - return sb.toString(); + widget.configurations.controller.replaceTextWithEmbeds( + diff.start, diff.deleted.length, diff.inserted, value.selection); } @override diff --git a/lib/src/widgets/toolbar/base_button/base_value_button.dart b/lib/src/widgets/toolbar/base_button/base_value_button.dart index 3c2b8046..edc53923 100644 --- a/lib/src/widgets/toolbar/base_button/base_value_button.dart +++ b/lib/src/widgets/toolbar/base_button/base_value_button.dart @@ -26,7 +26,9 @@ abstract class QuillToolbarBaseValueButtonState< QuillController get controller => widget.controller; - late V currentValue; + V? _currentValue; + V get currentValue => _currentValue!; + set currentValue(V value) => _currentValue = value; /// Callback to query the widget's state for the value to be assigned to currentState V get currentStateValue; @@ -35,6 +37,7 @@ abstract class QuillToolbarBaseValueButtonState< void initState() { super.initState(); controller.addListener(didChangeEditingValue); + addExtraListener(); } @override @@ -50,6 +53,7 @@ abstract class QuillToolbarBaseValueButtonState< @override void dispose() { controller.removeListener(didChangeEditingValue); + removeExtraListener(widget); super.dispose(); } @@ -58,11 +62,17 @@ abstract class QuillToolbarBaseValueButtonState< super.didUpdateWidget(oldWidget); if (oldWidget.controller != controller) { oldWidget.controller.removeListener(didChangeEditingValue); + removeExtraListener(oldWidget); controller.addListener(didChangeEditingValue); + addExtraListener(); currentValue = currentStateValue; } } + /// Extra listeners allow a subclass to listen to an external event that can affect its currentValue + void addExtraListener() {} + void removeExtraListener(covariant W oldWidget) {} + String get defaultTooltip; String get tooltip { @@ -96,3 +106,12 @@ abstract class QuillToolbarBaseValueButtonState< baseButtonExtraOptions?.afterButtonPressed; } } + +typedef QuillToolbarToggleStyleBaseButton = QuillToolbarBaseValueButton< + QuillToolbarToggleStyleButtonOptions, + QuillToolbarToggleStyleButtonExtraOptions>; + +typedef QuillToolbarToggleStyleBaseButtonState< + W extends QuillToolbarToggleStyleBaseButton> + = QuillToolbarBaseValueButtonState; diff --git a/lib/src/widgets/toolbar/base_toolbar.dart b/lib/src/widgets/toolbar/base_toolbar.dart index 114276ce..e9ee529e 100644 --- a/lib/src/widgets/toolbar/base_toolbar.dart +++ b/lib/src/widgets/toolbar/base_toolbar.dart @@ -10,6 +10,7 @@ import 'simple_toolbar.dart'; export '../../models/config/toolbar/base_button_configurations.dart'; export '../../models/config/toolbar/simple_toolbar_configurations.dart'; export 'buttons/clear_format_button.dart'; +export 'buttons/clipboard_button.dart'; export 'buttons/color/color_button.dart'; export 'buttons/custom_button_button.dart'; export 'buttons/font_family_button.dart'; diff --git a/lib/src/widgets/toolbar/buttons/clipboard_button.dart b/lib/src/widgets/toolbar/buttons/clipboard_button.dart new file mode 100644 index 00000000..93d1ba35 --- /dev/null +++ b/lib/src/widgets/toolbar/buttons/clipboard_button.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_clipboard/super_clipboard.dart'; + +import '../../../../extensions.dart'; +import '../../../../flutter_quill.dart'; +import '../../../l10n/extensions/localizations.dart'; +import '../base_button/base_value_button.dart'; + +enum ClipboardAction { cut, copy, paste } + +class ClipboardMonitor { + bool _canPaste = false; + bool get canPaste => _canPaste; + Timer? _timer; + + void monitorClipboard(bool add, void Function() listener) { + if (kIsWeb) return; + if (add) { + _timer = Timer.periodic( + const Duration(seconds: 1), (timer) => _update(listener)); + } else { + _timer?.cancel(); + } + } + + Future _update(void Function() listener) async { + final reader = await SystemClipboard.instance?.read(); + if (reader != null) { + final available = reader.platformFormats; + if (_canPaste != available.isNotEmpty) { + _canPaste = available.isNotEmpty; + listener(); + } + } + } +} + +class QuillToolbarClipboardButton extends QuillToolbarToggleStyleBaseButton { + QuillToolbarClipboardButton( + {required super.controller, + required this.clipboardAction, + super.options = const QuillToolbarToggleStyleButtonOptions(), + super.key}); + + final ClipboardAction clipboardAction; + + final ClipboardMonitor _monitor = ClipboardMonitor(); + + @override + State createState() => QuillToolbarClipboardButtonState(); +} + +class QuillToolbarClipboardButtonState + extends QuillToolbarToggleStyleBaseButtonState< + QuillToolbarClipboardButton> { + @override + bool get currentStateValue { + switch (widget.clipboardAction) { + case ClipboardAction.cut: + return !controller.readOnly && !controller.selection.isCollapsed; + case ClipboardAction.copy: + return !controller.selection.isCollapsed; + case ClipboardAction.paste: + return !controller.readOnly && (kIsWeb || widget._monitor.canPaste); + } + } + + void _listenClipboardStatus() => didChangeEditingValue(); + + @override + void addExtraListener() { + if (widget.clipboardAction == ClipboardAction.paste) { + widget._monitor.monitorClipboard(true, _listenClipboardStatus); + } + } + + @override + void removeExtraListener(covariant QuillToolbarClipboardButton oldWidget) { + if (widget.clipboardAction == ClipboardAction.paste) { + oldWidget._monitor.monitorClipboard(false, _listenClipboardStatus); + } + } + + @override + String get defaultTooltip => switch (widget.clipboardAction) { + ClipboardAction.cut => context.loc.cut, + ClipboardAction.copy => context.loc.copy, + ClipboardAction.paste => context.loc.paste, + }; + + IconData get _icon => switch (widget.clipboardAction) { + ClipboardAction.cut => Icons.cut_outlined, + ClipboardAction.copy => Icons.copy_outlined, + ClipboardAction.paste => Icons.paste_outlined, + }; + + void _onPressed() { + switch (widget.clipboardAction) { + case ClipboardAction.cut: + controller.clipboardSelection(false); + break; + case ClipboardAction.copy: + controller.clipboardSelection(true); + break; + case ClipboardAction.paste: + controller.clipboardPaste(); + break; + } + afterButtonPressed?.call(); + } + + @override + Widget build(BuildContext context) { + final childBuilder = options.childBuilder ?? + context.quillToolbarBaseButtonOptions?.childBuilder; + if (childBuilder != null) { + return childBuilder( + options, + QuillToolbarToggleStyleButtonExtraOptions( + context: context, + controller: controller, + onPressed: _onPressed, + isToggled: currentValue, + ), + ); + } + + return UtilityWidgets.maybeTooltip( + message: tooltip, + child: QuillToolbarIconButton( + icon: Icon( + _icon, + size: iconSize * iconButtonFactor, + ), + isSelected: false, + onPressed: currentValue ? _onPressed : null, + afterPressed: afterButtonPressed, + iconTheme: iconTheme, + )); + } +} diff --git a/lib/src/widgets/toolbar/buttons/toggle_style_button.dart b/lib/src/widgets/toolbar/buttons/toggle_style_button.dart index fa065190..017ccb74 100644 --- a/lib/src/widgets/toolbar/buttons/toggle_style_button.dart +++ b/lib/src/widgets/toolbar/buttons/toggle_style_button.dart @@ -20,9 +20,7 @@ typedef ToggleStyleButtonBuilder = Widget Function( QuillIconTheme? iconTheme, ]); -class QuillToolbarToggleStyleButton extends QuillToolbarBaseValueButton< - QuillToolbarToggleStyleButtonOptions, - QuillToolbarToggleStyleButtonExtraOptions> { +class QuillToolbarToggleStyleButton extends QuillToolbarToggleStyleBaseButton { const QuillToolbarToggleStyleButton({ required super.controller, required this.attribute, @@ -38,11 +36,8 @@ class QuillToolbarToggleStyleButton extends QuillToolbarBaseValueButton< } class QuillToolbarToggleStyleButtonState - extends QuillToolbarBaseValueButtonState< - QuillToolbarToggleStyleButton, - QuillToolbarToggleStyleButtonOptions, - QuillToolbarToggleStyleButtonExtraOptions, - bool> { + extends QuillToolbarToggleStyleBaseButtonState< + QuillToolbarToggleStyleButton> { Style get _selectionStyle => controller.getSelectionStyle(); @override diff --git a/lib/src/widgets/toolbar/simple_toolbar.dart b/lib/src/widgets/toolbar/simple_toolbar.dart index ccf0c4e4..789717be 100644 --- a/lib/src/widgets/toolbar/simple_toolbar.dart +++ b/lib/src/widgets/toolbar/simple_toolbar.dart @@ -18,6 +18,8 @@ class QuillSimpleToolbar extends StatelessWidget /// The configurations for the toolbar widget of flutter quill final QuillSimpleToolbarConfigurations configurations; + double get _toolbarSize => configurations.toolbarSize * 1.4; + @override Widget build(BuildContext context) { final theEmbedButtons = configurations.embedButtons; @@ -58,6 +60,14 @@ class QuillSimpleToolbar extends StatelessWidget final axis = toolbarConfigurations.axis; final globalController = configurations.controller; + final divider = SizedBox( + height: _toolbarSize, + child: QuillToolbarDivider( + axis, + color: configurations.sectionDividerColor, + space: configurations.sectionDividerSpace, + )); + return [ if (configurations.showUndo) QuillToolbarHistoryButton( @@ -160,11 +170,7 @@ class QuillSimpleToolbar extends StatelessWidget isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - QuillToolbarDivider( - axis, - color: configurations.sectionDividerColor, - space: configurations.sectionDividerSpace, - ), + divider, if (configurations.showAlignmentButtons) QuillToolbarSelectAlignmentButtons( controller: globalController, @@ -188,11 +194,7 @@ class QuillSimpleToolbar extends StatelessWidget isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - QuillToolbarDivider( - axis, - color: configurations.sectionDividerColor, - space: configurations.sectionDividerSpace, - ), + divider, if (configurations.showHeaderStyle) ...[ if (configurations.headerStyleType.isOriginal) QuillToolbarSelectHeaderStyleDropdownButton( @@ -213,11 +215,7 @@ class QuillSimpleToolbar extends StatelessWidget (isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - QuillToolbarDivider( - axis, - color: configurations.sectionDividerColor, - space: configurations.sectionDividerSpace, - ), + divider, if (configurations.showListNumbers) QuillToolbarToggleStyleButton( attribute: Attribute.ol, @@ -244,11 +242,7 @@ class QuillSimpleToolbar extends StatelessWidget if (configurations.showDividers && isButtonGroupShown[3] && (isButtonGroupShown[4] || isButtonGroupShown[5])) ...[ - QuillToolbarDivider( - axis, - color: configurations.sectionDividerColor, - space: configurations.sectionDividerSpace, - ), + divider, ], if (configurations.showQuote) QuillToolbarToggleStyleButton( @@ -271,11 +265,7 @@ class QuillSimpleToolbar extends StatelessWidget if (configurations.showDividers && isButtonGroupShown[4] && isButtonGroupShown[5]) - QuillToolbarDivider( - axis, - color: configurations.sectionDividerColor, - space: configurations.sectionDividerSpace, - ), + divider, if (configurations.showLink) toolbarConfigurations.linkStyleType.isOriginal ? QuillToolbarLinkStyleButton( @@ -291,13 +281,26 @@ class QuillSimpleToolbar extends StatelessWidget controller: globalController, options: toolbarConfigurations.buttonOptions.search, ), + if (configurations.showClipboardCut) + QuillToolbarClipboardButton( + options: toolbarConfigurations.buttonOptions.clipboardCut, + controller: globalController, + clipboardAction: ClipboardAction.cut, + ), + if (configurations.showClipboardCopy) + QuillToolbarClipboardButton( + options: toolbarConfigurations.buttonOptions.clipboardCopy, + controller: globalController, + clipboardAction: ClipboardAction.copy, + ), + if (configurations.showClipboardPaste) + QuillToolbarClipboardButton( + options: toolbarConfigurations.buttonOptions.clipboardPaste, + controller: globalController, + clipboardAction: ClipboardAction.paste, + ), if (configurations.customButtons.isNotEmpty) ...[ - if (configurations.showDividers) - QuillToolbarDivider( - axis, - color: configurations.sectionDividerColor, - space: configurations.sectionDividerSpace, - ), + if (configurations.showDividers) divider, for (final customButton in configurations.customButtons) QuillToolbarCustomButton( options: customButton, @@ -347,11 +350,10 @@ class QuillSimpleToolbar extends StatelessWidget ), constraints: BoxConstraints.tightFor( height: configurations.axis == Axis.horizontal - ? configurations.toolbarSize - : null, - width: configurations.axis == Axis.vertical - ? configurations.toolbarSize + ? _toolbarSize : null, + width: + configurations.axis == Axis.vertical ? _toolbarSize : null, ), child: QuillToolbarArrowIndicatedButtonList( axis: configurations.axis, diff --git a/test/bug_fix_test.dart b/test/bug_fix_test.dart index fb135f12..7c4f565b 100644 --- a/test/bug_fix_test.dart +++ b/test/bug_fix_test.dart @@ -65,7 +65,6 @@ void main() { configurations: QuillEditorConfigurations( controller: controller, // ignore: avoid_redundant_argument_values - readOnly: false, ), ); }); diff --git a/test/widgets/controller_test.dart b/test/widgets/controller_test.dart index 72fc6f94..10133064 100644 --- a/test/widgets/controller_test.dart +++ b/test/widgets/controller_test.dart @@ -6,6 +6,7 @@ import 'package:test/test.dart'; void main() { const testDocumentContents = 'data'; late QuillController controller; + WidgetsFlutterBinding.ensureInitialized(); setUp(() { controller = QuillController.basic() @@ -119,6 +120,26 @@ void main() { expect((result[1].value as Embeddable).type, BlockEmbed.imageType); }); + test('getAllIndividualSelectionStylesAndEmbed mixed', () { + 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.getPlainText(), 'ld plain ita', + reason: 'Selection spans 3 styles'); + // + final result = controller.getAllIndividualSelectionStylesAndEmbed(); + expect(result.length, 2); + expect(result[0].offset, 0); + expect(result[0].length, 2, reason: 'First style is 2 characters bold'); + expect(result[0].value, const Style().put(Attribute.bold)); + expect(result[1].offset, 9); + expect(result[1].length, 3, reason: 'Last style is 3 characters italic'); + expect(result[1].value, const Style().put(Attribute.italic)); + }); + test('getPlainText', () { controller.updateSelection( const TextSelection(baseOffset: 0, extentOffset: 4), @@ -302,5 +323,37 @@ void main() { expect(controller.document.toDelta(), 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'); + }); }); } diff --git a/test/widgets/editor_test.dart b/test/widgets/editor_test.dart index 72db9c2b..2a4bd8ab 100644 --- a/test/widgets/editor_test.dart +++ b/test/widgets/editor_test.dart @@ -27,7 +27,6 @@ void main() { configurations: QuillEditorConfigurations( controller: controller, // ignore: avoid_redundant_argument_values - readOnly: false, ), ), ), @@ -47,7 +46,6 @@ void main() { configurations: QuillEditorConfigurations( controller: controller, // ignore: avoid_redundant_argument_values - readOnly: false, autoFocus: true, expands: true, contentInsertionConfiguration: ContentInsertionConfiguration( @@ -121,7 +119,6 @@ void main() { configurations: QuillEditorConfigurations( controller: controller, // ignore: avoid_redundant_argument_values - readOnly: false, autoFocus: true, expands: true, contextMenuBuilder: customBuilder,