From 9e2d3d50c0d64dd5bebc4ba5eabee96a0cd72b13 Mon Sep 17 00:00:00 2001 From: Douglas Ward Date: Thu, 25 Apr 2024 18:08:23 -0600 Subject: [PATCH] Add: Clipboard toolbar buttons --- example/lib/screens/quill/quill_screen.dart | 2 +- .../config/editor/editor_configurations.dart | 14 +- .../simple_toolbar_button_options.dart | 8 ++ .../simple_toolbar_configurations.dart | 8 ++ lib/src/widgets/quill/quill_controller.dart | 13 +- lib/src/widgets/raw_editor/raw_editor.dart | 4 +- .../base_button/base_value_button.dart | 16 ++- .../toolbar/buttons/clipboard_button.dart | 125 ++++++++++++++++++ .../toolbar/buttons/toggle_style_button.dart | 10 +- lib/src/widgets/toolbar/simple_toolbar.dart | 21 +++ test/bug_fix_test.dart | 1 - test/widgets/editor_test.dart | 3 - 12 files changed, 190 insertions(+), 35 deletions(-) create mode 100644 lib/src/widgets/toolbar/buttons/clipboard_button.dart 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/models/config/editor/editor_configurations.dart b/lib/src/models/config/editor/editor_configurations.dart index cb64336a..8bf3df67 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.disableClipboard = false, this.textSelectionThemeData, this.showCursor, @@ -95,7 +94,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; /// Disable Clipboard features /// @@ -132,11 +131,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 @@ -303,7 +302,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. @@ -357,10 +356,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 @@ -420,7 +419,6 @@ class QuillEditorConfigurations extends Equatable { sharedConfigurations: sharedConfigurations ?? this.sharedConfigurations, controller: controller ?? this.controller, placeholder: placeholder ?? this.placeholder, - readOnly: readOnly ?? this.readOnly, disableClipboard: disableClipboard ?? this.disableClipboard, scrollable: scrollable ?? this.scrollable, scrollBottomInset: scrollBottomInset ?? this.scrollBottomInset, 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..f72cf178 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,10 @@ 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 +117,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..d4a97fa5 100644 --- a/lib/src/models/config/toolbar/simple_toolbar_configurations.dart +++ b/lib/src/models/config/toolbar/simple_toolbar_configurations.dart @@ -107,6 +107,11 @@ 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 +200,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/widgets/quill/quill_controller.dart b/lib/src/widgets/quill/quill_controller.dart index 56bbcd29..23ba3bda 100644 --- a/lib/src/widgets/quill/quill_controller.dart +++ b/lib/src/widgets/quill/quill_controller.dart @@ -6,18 +6,10 @@ 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/delta_x.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 '../../utils/delta.dart'; -import '../../utils/embeds.dart'; typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); typedef DeleteCallback = void Function(int cursorPosition, bool forward); @@ -31,6 +23,7 @@ class QuillController extends ChangeNotifier { this.onDelete, this.onSelectionCompleted, this.onSelectionChanged, + this.readOnly = false, }) : _document = document, _selection = selection; @@ -464,7 +457,7 @@ class QuillController extends ChangeNotifier { String get pastePlainText => _pastePlainText; List get pasteStyleAndEmbed => _pasteStyleAndEmbed; - bool readOnly = false; + bool readOnly; ImageUrl? _copiedImageUrl; ImageUrl? get copiedImageUrl => _copiedImageUrl; diff --git a/lib/src/widgets/raw_editor/raw_editor.dart b/lib/src/widgets/raw_editor/raw_editor.dart index 12844828..b69b7515 100644 --- a/lib/src/widgets/raw_editor/raw_editor.dart +++ b/lib/src/widgets/raw_editor/raw_editor.dart @@ -29,9 +29,7 @@ class QuillRawEditor extends StatefulWidget { configurations.maxHeight == null || configurations.minHeight == null || configurations.maxHeight! >= configurations.minHeight!, - 'maxHeight cannot be null') { - configurations.controller.readOnly = configurations.readOnly; - } + 'maxHeight cannot be null'); final QuillRawEditorConfigurations configurations; 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..1589096c 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,7 @@ abstract class QuillToolbarBaseValueButtonState< baseButtonExtraOptions?.afterButtonPressed; } } + +typedef QuillToolbarToggleStyleBaseButton = QuillToolbarBaseValueButton; + +typedef QuillToolbarToggleStyleBaseButtonState = QuillToolbarBaseValueButtonState; \ No newline at end of file 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..89403c3e --- /dev/null +++ b/lib/src/widgets/toolbar/buttons/clipboard_button.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/material.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 { + final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); + + Timer? _timer; + + void monitorClipboard ( bool add, void Function() listener ) { + if ( add ) { + _clipboardStatus.addListener(listener); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) => _clipboardStatus.update()); + } else { + _timer?.cancel(); + _clipboardStatus.removeListener(listener); + } + } +} + +class QuillToolbarClipboardButton extends QuillToolbarToggleStyleBaseButton { + QuillToolbarClipboardButton({required super.controller, required this.clipboard, required super.options, super.key}); + + final ClipboardAction clipboard; + + final ClipboardMonitor _monitor = ClipboardMonitor(); + + @override + State createState() => QuillToolbarClipboardButtonState(); +} + +class QuillToolbarClipboardButtonState extends QuillToolbarToggleStyleBaseButtonState { + @override + bool get currentStateValue { + switch (widget.clipboard) { + case ClipboardAction.cut: + return !controller.readOnly && !controller.selection.isCollapsed; + case ClipboardAction.copy: + return !controller.selection.isCollapsed; + case ClipboardAction.paste: + return !controller.readOnly && widget._monitor._clipboardStatus.value == ClipboardStatus.pasteable; + } + } + + void _listenClipboardStatus () => didChangeEditingValue(); + + @override + void addExtraListener () { + if ( widget.clipboard == ClipboardAction.paste) { + widget._monitor.monitorClipboard(true, _listenClipboardStatus); + } + } + + @override + void removeExtraListener (covariant QuillToolbarClipboardButton oldWidget) { + if ( widget.clipboard == ClipboardAction.paste) { + oldWidget._monitor.monitorClipboard(false, _listenClipboardStatus); + } + } + + @override + String get defaultTooltip => switch (widget.clipboard) { + ClipboardAction.cut => 'cut', + ClipboardAction.copy => context.loc.copy, + ClipboardAction.paste => 'paste', + }; + + IconData get _icon => switch (widget.clipboard) { + ClipboardAction.cut => Icons.cut_outlined, + ClipboardAction.copy => Icons.copy_outlined, + ClipboardAction.paste => Icons.paste_outlined, + }; + + void _onPressed() { + switch (widget.clipboard) { + 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 32db56ec..1831a5ff 100644 --- a/lib/src/widgets/toolbar/buttons/toggle_style_button.dart +++ b/lib/src/widgets/toolbar/buttons/toggle_style_button.dart @@ -21,9 +21,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, @@ -39,11 +37,7 @@ class QuillToolbarToggleStyleButton extends QuillToolbarBaseValueButton< } class QuillToolbarToggleStyleButtonState - extends QuillToolbarBaseValueButtonState< - QuillToolbarToggleStyleButton, - QuillToolbarToggleStyleButtonOptions, - QuillToolbarToggleStyleButtonExtraOptions, - bool> { + extends QuillToolbarToggleStyleBaseButtonState { 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..0952cd11 100644 --- a/lib/src/widgets/toolbar/simple_toolbar.dart +++ b/lib/src/widgets/toolbar/simple_toolbar.dart @@ -7,6 +7,7 @@ import '../utils/provider.dart'; import 'base_toolbar.dart'; import 'buttons/alignment/select_alignment_buttons.dart'; import 'buttons/arrow_indicated_list_button.dart'; +import 'buttons/clipboard_button.dart'; class QuillSimpleToolbar extends StatelessWidget implements PreferredSizeWidget { @@ -291,6 +292,26 @@ class QuillSimpleToolbar extends StatelessWidget controller: globalController, options: toolbarConfigurations.buttonOptions.search, ), + + if (configurations.showClipboardCut) + QuillToolbarClipboardButton( + options: toolbarConfigurations.buttonOptions.clipboardCut, + controller: globalController, + clipboard: ClipboardAction.cut, + ), + if (configurations.showClipboardCopy) + QuillToolbarClipboardButton( + options: toolbarConfigurations.buttonOptions.clipboardCopy, + controller: globalController, + clipboard: ClipboardAction.copy, + ), + if (configurations.showClipboardPaste) + QuillToolbarClipboardButton( + options: toolbarConfigurations.buttonOptions.clipboardPaste, + controller: globalController, + clipboard: ClipboardAction.paste, + ), + if (configurations.customButtons.isNotEmpty) ...[ if (configurations.showDividers) QuillToolbarDivider( 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/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,