diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df24919..8e8c2dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +# [6.4.4] +* Increased compatibility with Flutter widget tests. + +# [6.4.3] +* Update dependencies (collection: 1.17.0, flutter_keyboard_visibility: 5.4.0, quiver: 3.2.1, tuple: 2.0.1, url_launcher: 6.1.9, characters: 1.2.1, i18n_extension: 7.0.0, device_info_plus: 8.1.0) + +# [6.4.2] +* Replace `buildToolbar` with `contextMenuBuilder`. + +# [6.4.1] +* Control the detect word boundary behaviour. + +# [6.4.0] +* Use `axis` to make the toolbar vertical. +* Use `toolbarIconCrossAlignment` to align the toolbar icons on the cross axis. +* Breaking change: `QuillToolbar`'s parameter `toolbarHeight` was renamed to `toolbarSize`. + +# [6.3.5] +* Ability to add custom shortcuts. + +# [6.3.4] +* Update clipboard status prior to showing selected text overlay. + +# [6.3.3] +* Fixed handling of mac intents. + +# [6.3.2] +* Added `unknownEmbedBuilder` to QuillEditor. +* Fix error style when input chinese japanese or korean. + # [6.3.1] * Add color property to the basic factory function. diff --git a/README.md b/README.md index 5ec17c82..bd809b78 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ You can then write this to storage. To open a FlutterQuill editor with an existing JSON representation that you've previously stored, you can do something like this: ```dart -var myJSON = jsonDecode(incomingJSONText); +var myJSON = jsonDecode(r'{"insert":"hello\n"}'); _controller = QuillController( document: Document.fromJson(myJSON), selection: TextSelection.collapsed(offset: 0), @@ -346,7 +346,7 @@ QuillToolbar(locale: Locale('fr'), ...) QuillEditor(locale: Locale('fr'), ...) ``` -Currently, translations are available for these 25 locales: +Currently, translations are available for these 26 locales: * `Locale('en')` * `Locale('ar')` @@ -367,6 +367,7 @@ Currently, translations are available for these 25 locales: * `Locale('pl')` * `Locale('vi')` * `Locale('id')` +* `Locale('ms')` * `Locale('nl')` * `Locale('no')` * `Locale('fa')` diff --git a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart index a72f1996..aef7c9e7 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart @@ -80,7 +80,6 @@ class ImageVideoUtils { context: context, builder: (ctx) => AlertDialog( contentPadding: EdgeInsets.zero, - backgroundColor: Colors.transparent, content: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index ef77e77a..949371b5 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -477,7 +477,7 @@ class PreserveInlineStylesRule extends InsertRule { } final itr = DeltaIterator(document); - final prev = itr.skip(index); + final prev = itr.skip(len == 0 ? index : index + 1); if (prev == null || prev.data is! String || (prev.data as String).contains('\n')) { diff --git a/lib/src/translations/toolbar.i18n.dart b/lib/src/translations/toolbar.i18n.dart index a5c1c651..fa5ea0f4 100644 --- a/lib/src/translations/toolbar.i18n.dart +++ b/lib/src/translations/toolbar.i18n.dart @@ -898,7 +898,41 @@ extension Localization on String { 'Next': 'הבא', 'Camera': 'מצלמה', 'Video': 'וידאו', - } + }, + 'ms': { + 'Paste a link': 'Tampal Pautan', + 'Ok': 'Ok', + 'Select Color': 'Pilih Warna', + 'Gallery': 'Galeri', + 'Link': 'Pautan', + 'Please first select some text to transform into a link.': + 'Sila pilih beberapa patah perkataan' + ' untuk diubah menjadi pautan.', + 'Open': 'Buka', + 'Copy': 'Salin', + 'Remove': 'Buang', + 'Save': 'Simpan', + 'Zoom': 'Zum', + 'Saved': 'Telah Disimpan', + 'Text': 'Perkataan', + 'What is entered is not a link': 'Apa yang diisi bukan pautan', + 'Resize': 'Ubah saiz', + 'Width': 'Lebar', + 'Height': 'Tinggi', + 'Size': 'Saiz', + 'Small': 'Kecil', + 'Large': 'Besar', + 'Huge': 'Amat Besar', + 'Clear': 'Padam', + 'Font': 'Fon', + 'Search': 'Carian', + 'matches': 'padanan', + 'showing match': 'menunjukkan padanan', + 'Prev': 'Sebelum', + 'Next': 'Seterusnya', + 'Camera': 'Kamera', + 'Video': 'Video', + }, }; String get i18n => localize(this, _t); diff --git a/lib/src/utils/platform.dart b/lib/src/utils/platform.dart index 27d183aa..96cb866b 100644 --- a/lib/src/utils/platform.dart +++ b/lib/src/utils/platform.dart @@ -26,6 +26,10 @@ bool isAppleOS([TargetPlatform? targetPlatform]) { } Future isIOSSimulator() async { + if (!isAppleOS()) { + return false; + } + final deviceInfo = DeviceInfoPlugin(); final osInfo = await deviceInfo.deviceInfo; diff --git a/lib/src/widgets/delegate.dart b/lib/src/widgets/delegate.dart index a75b64e7..ce8e65a3 100644 --- a/lib/src/widgets/delegate.dart +++ b/lib/src/widgets/delegate.dart @@ -66,7 +66,8 @@ class EditorTextSelectionGestureDetectorBuilder { /// Creates a [EditorTextSelectionGestureDetectorBuilder]. /// /// The [delegate] must not be null. - EditorTextSelectionGestureDetectorBuilder({required this.delegate}); + EditorTextSelectionGestureDetectorBuilder( + {required this.delegate, this.detectWordBoundary = true}); /// The delegate for this [EditorTextSelectionGestureDetectorBuilder]. /// @@ -83,6 +84,8 @@ class EditorTextSelectionGestureDetectorBuilder { /// a stylus. bool shouldShowSelectionToolbar = true; + bool detectWordBoundary = true; + /// The [State] of the [EditableText] for which the builder will provide a /// [EditorTextSelectionGestureDetector]. @protected @@ -337,24 +340,28 @@ class EditorTextSelectionGestureDetectorBuilder { /// /// The [child] or its subtree should contain [EditableText]. Widget build( - {required HitTestBehavior behavior, required Widget child, Key? key}) { + {required HitTestBehavior behavior, + required Widget child, + Key? key, + bool detectWordBoundary = true}) { return EditorTextSelectionGestureDetector( - key: key, - onTapDown: onTapDown, - onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, - onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, - onSingleTapUp: onSingleTapUp, - onSingleTapCancel: onSingleTapCancel, - onSingleLongTapStart: onSingleLongTapStart, - onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, - onSingleLongTapEnd: onSingleLongTapEnd, - onDoubleTapDown: onDoubleTapDown, - onSecondarySingleTapUp: onSecondarySingleTapUp, - onDragSelectionStart: onDragSelectionStart, - onDragSelectionUpdate: onDragSelectionUpdate, - onDragSelectionEnd: onDragSelectionEnd, - behavior: behavior, - child: child, - ); + key: key, + onTapDown: onTapDown, + onForcePressStart: + delegate.forcePressEnabled ? onForcePressStart : null, + onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, + onSingleTapUp: onSingleTapUp, + onSingleTapCancel: onSingleTapCancel, + onSingleLongTapStart: onSingleLongTapStart, + onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, + onSingleLongTapEnd: onSingleLongTapEnd, + onDoubleTapDown: onDoubleTapDown, + onSecondarySingleTapUp: onSecondarySingleTapUp, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + behavior: behavior, + detectWordBoundary: detectWordBoundary, + child: child); } } diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 7dae5136..7c8d7c10 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -180,6 +180,9 @@ class QuillEditor extends StatefulWidget { this.floatingCursorDisabled = false, this.textSelectionControls, this.onImagePaste, + this.customShortcuts, + this.customActions, + this.detectWordBoundary = true, Key? key}) : super(key: key); @@ -394,6 +397,11 @@ class QuillEditor extends StatefulWidget { /// Returns the url of the image if the image should be inserted. final Future Function(Uint8List imageBytes)? onImagePaste; + final Map? customShortcuts; + final Map>? customActions; + + final bool detectWordBoundary; + @override QuillEditorState createState() => QuillEditorState(); } @@ -408,7 +416,8 @@ class QuillEditorState extends State void initState() { super.initState(); _selectionGestureDetectorBuilder = - _QuillEditorSelectionGestureDetectorBuilder(this); + _QuillEditorSelectionGestureDetectorBuilder( + this, widget.detectWordBoundary); } @override @@ -458,12 +467,8 @@ class QuillEditorState extends State readOnly: widget.readOnly, placeholder: widget.placeholder, onLaunchUrl: widget.onLaunchUrl, - toolbarOptions: ToolbarOptions( - copy: showSelectionToolbar, - cut: showSelectionToolbar, - paste: showSelectionToolbar, - selectAll: showSelectionToolbar, - ), + contextMenuBuilder: + showSelectionToolbar ? RawEditor.defaultContextMenuBuilder : null, showSelectionHandles: isMobile(theme.platform), showCursor: widget.showCursor, cursorStyle: CursorStyle( @@ -494,16 +499,18 @@ class QuillEditorState extends State readOnly, ) => _buildCustomBlockEmbed( - node, - context, - controller, - readOnly, - widget.unknownEmbedBuilder, - ), + node, + context, + controller, + readOnly, + widget.unknownEmbedBuilder, + ), linkActionPickerDelegate: widget.linkActionPickerDelegate, customStyleBuilder: widget.customStyleBuilder, floatingCursorDisabled: widget.floatingCursorDisabled, onImagePaste: widget.onImagePaste, + customShortcuts: widget.customShortcuts, + customActions: widget.customActions, ); final editor = I18n( @@ -511,6 +518,7 @@ class QuillEditorState extends State child: selectionEnabled ? _selectionGestureDetectorBuilder.build( behavior: HitTestBehavior.translucent, + detectWordBoundary: widget.detectWordBoundary, child: child, ) : child, @@ -541,7 +549,7 @@ class QuillEditorState extends State EmbedsBuilder? unknownEmbedBuilder, ) { final builders = widget.embedBuilders; - + var _node = node; // Creates correct node for custom embed if (node.value.type == BlockEmbed.customType) { @@ -555,7 +563,7 @@ class QuillEditorState extends State } } } - + if (unknownEmbedBuilder != null) { return unknownEmbedBuilder(context, controller, _node, readOnly); } @@ -584,10 +592,12 @@ class QuillEditorState extends State class _QuillEditorSelectionGestureDetectorBuilder extends EditorTextSelectionGestureDetectorBuilder { - _QuillEditorSelectionGestureDetectorBuilder(this._state) - : super(delegate: _state); + _QuillEditorSelectionGestureDetectorBuilder( + this._state, this._detectWordBoundary) + : super(delegate: _state, detectWordBoundary: _detectWordBoundary); final QuillEditorState _state; + final bool _detectWordBoundary; @override void onForcePressStart(ForcePressDetails details) { @@ -705,9 +715,15 @@ class _QuillEditorSelectionGestureDetectorBuilder case PointerDeviceKind.unknown: // On macOS/iOS/iPadOS a touch tap places the cursor at the edge // of the word. - renderEditor! - ..selectWordEdge(SelectionChangedCause.tap) - ..onSelectionCompleted(); + if (_detectWordBoundary) { + renderEditor! + ..selectWordEdge(SelectionChangedCause.tap) + ..onSelectionCompleted(); + } else { + renderEditor! + ..selectPosition(cause: SelectionChangedCause.tap) + ..onSelectionCompleted(); + } break; case PointerDeviceKind.trackpad: // TODO: Handle this case. diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 1cd69e22..75a4203c 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; // ignore: unnecessary_import import 'dart:typed_data'; @@ -57,12 +58,7 @@ class RawEditor extends StatefulWidget { this.readOnly = false, this.placeholder, this.onLaunchUrl, - this.toolbarOptions = const ToolbarOptions( - copy: true, - cut: true, - paste: true, - selectAll: true, - ), + this.contextMenuBuilder = defaultContextMenuBuilder, this.showSelectionHandles = false, bool? showCursor, this.textCapitalization = TextCapitalization.none, @@ -70,6 +66,8 @@ class RawEditor extends StatefulWidget { this.minHeight, this.maxContentWidth, this.customStyles, + this.customShortcuts, + this.customActions, this.expands = false, this.autoFocus = false, this.keyboardAppearance = Brightness.light, @@ -112,11 +110,24 @@ class RawEditor extends StatefulWidget { /// a link in the document. final ValueChanged? onLaunchUrl; - /// Configuration of toolbar options. + /// Builds the text selection toolbar when requested by the user. + /// + /// See also: + /// * [EditableText.contextMenuBuilder], which builds the default + /// text selection toolbar for [EditableText]. /// - /// By default, all options are enabled. If [readOnly] is true, - /// paste and cut will be disabled regardless. - final ToolbarOptions toolbarOptions; + /// If not provided, no context menu will be shown. + final QuillEditorContextMenuBuilder? contextMenuBuilder; + + static Widget defaultContextMenuBuilder( + BuildContext context, + RawEditorState state, + ) { + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: state.contextMenuButtonItems, + anchors: state.contextMenuAnchors, + ); + } /// Whether to show selection handles. /// @@ -227,6 +238,9 @@ class RawEditor extends StatefulWidget { final Future Function(Uint8List imageBytes)? onImagePaste; + final Map? customShortcuts; + final Map>? customActions; + /// Builder function for embeddable objects. final EmbedsBuilder embedBuilder; final LinkActionPickerDelegate linkActionPickerDelegate; @@ -288,6 +302,74 @@ class RawEditorState extends EditorState TextDirection get _textDirection => Directionality.of(context); + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu for [RawEditor]. + /// + /// Copied from [EditableTextState]. + List get contextMenuButtonItems { + return EditableText.getEditableButtonItems( + clipboardStatus: _clipboardStatus.value, + onCopy: copyEnabled + ? () => copySelection(SelectionChangedCause.toolbar) + : null, + onCut: + cutEnabled ? () => cutSelection(SelectionChangedCause.toolbar) : null, + onPaste: + pasteEnabled ? () => pasteText(SelectionChangedCause.toolbar) : null, + onSelectAll: selectAllEnabled + ? () => selectAll(SelectionChangedCause.toolbar) + : null, + ); + } + + /// Returns the anchor points for the default context menu. + /// + /// Copied from [EditableTextState]. + TextSelectionToolbarAnchors get contextMenuAnchors { + final glyphHeights = _getGlyphHeights(); + final selection = textEditingValue.selection; + final points = renderEditor.getEndpointsForSelection(selection); + return TextSelectionToolbarAnchors.fromSelection( + renderBox: renderEditor, + startGlyphHeight: glyphHeights.item1, + endGlyphHeight: glyphHeights.item2, + selectionEndpoints: points, + ); + } + + /// Gets the line heights at the start and end of the selection for the given + /// [RawEditorState]. + /// + /// Copied from [EditableTextState]. + Tuple2 _getGlyphHeights() { + final selection = textEditingValue.selection; + + // Only calculate handle rects if the text in the previous frame + // is the same as the text in the current frame. This is done because + // widget.renderObject contains the renderEditable from the previous frame. + // If the text changed between the current and previous frames then + // widget.renderObject.getRectForComposingRange might fail. In cases where + // the current frame is different from the previous we fall back to + // renderObject.preferredLineHeight. + final prevText = renderEditor.document.toPlainText(); + final currText = textEditingValue.text; + if (prevText != currText || !selection.isValid || selection.isCollapsed) { + return Tuple2( + renderEditor.preferredLineHeight(selection.base), + renderEditor.preferredLineHeight(selection.base), + ); + } + + final startCharacterRect = + renderEditor.getLocalRectForCaret(selection.base); + final endCharacterRect = + renderEditor.getLocalRectForCaret(selection.extent); + return Tuple2( + startCharacterRect.height, + endCharacterRect.height, + ); + } + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); @@ -428,9 +510,14 @@ class RawEditorState extends EditorState LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyL): const ApplyCheckListIntent(), + + if (widget.customShortcuts != null) ...widget.customShortcuts!, }, child: Actions( - actions: _actions, + actions: { + ..._actions, + if (widget.customActions != null) ...widget.customActions!, + }, child: Focus( focusNode: widget.focusNode, onKey: _onKey, @@ -758,6 +845,9 @@ class RawEditorState extends EditorState if (isKeyboardOS()) { _keyboardVisible = true; + } else if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { + // treat tests like a keyboard OS + _keyboardVisible = true; } else { // treat iOS Simulator like a keyboard OS isIOSSimulator().then((isIosSimulator) { @@ -964,13 +1054,15 @@ class RawEditorState extends EditorState value: textEditingValue, context: context, debugRequiredFor: widget, - toolbarLayerLink: _toolbarLayerLink, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, renderObject: renderEditor, selectionCtrls: widget.selectionCtrls, selectionDelegate: this, clipboardStatus: _clipboardStatus, + contextMenuBuilder: widget.contextMenuBuilder == null + ? null + : (context) => widget.contextMenuBuilder!(context, this), ); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.showHandles(); @@ -1431,7 +1523,14 @@ class RawEditorState extends EditorState @override void performSelector(String selectorName) { - // TODO: implement performSelector + final intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } } } @@ -2316,3 +2415,15 @@ class _ApplyCheckListAction extends Action { @override bool get isActionEnabled => true; } + +/// Signature for a widget builder that builds a context menu for the given +/// [RawEditorState]. +/// +/// See also: +/// +/// * [EditableTextContextMenuBuilder], which performs the same role for +/// [EditableText] +typedef QuillEditorContextMenuBuilder = Widget Function( + BuildContext context, + RawEditorState rawEditorState, +); 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 8b494dd1..5da7cef2 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 @@ -150,14 +150,15 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState } @override - bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; + bool get cutEnabled => widget.contextMenuBuilder != null && !widget.readOnly; @override - bool get copyEnabled => widget.toolbarOptions.copy; + bool get copyEnabled => widget.contextMenuBuilder != null; @override - bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; + bool get pasteEnabled => + widget.contextMenuBuilder != null && !widget.readOnly; @override - bool get selectAllEnabled => widget.toolbarOptions.selectAll; + bool get selectAllEnabled => widget.contextMenuBuilder != null; } diff --git a/lib/src/widgets/text_selection.dart b/lib/src/widgets/text_selection.dart index dd6d6c18..ad1398e0 100644 --- a/lib/src/widgets/text_selection.dart +++ b/lib/src/widgets/text_selection.dart @@ -69,7 +69,6 @@ class EditorTextSelectionOverlay { EditorTextSelectionOverlay({ required this.value, required this.context, - required this.toolbarLayerLink, required this.startHandleLayerLink, required this.endHandleLayerLink, required this.renderObject, @@ -77,14 +76,18 @@ class EditorTextSelectionOverlay { required this.selectionCtrls, required this.selectionDelegate, required this.clipboardStatus, + required this.contextMenuBuilder, this.onSelectionHandleTapped, this.dragStartBehavior = DragStartBehavior.start, this.handlesVisible = false, }) { - final overlay = Overlay.of(context, rootOverlay: true); - - _toolbarController = AnimationController( - duration: const Duration(milliseconds: 150), vsync: overlay); + // Clipboard status is only checked on first instance of + // ClipboardStatusNotifier + // if state has changed after creation, but prior to + // our listener being created + // we won't know the status unless there is forced update + // i.e. occasionally no paste + clipboardStatus.update(); } TextEditingValue value; @@ -114,10 +117,6 @@ class EditorTextSelectionOverlay { /// Debugging information for explaining why the [Overlay] is required. final Widget debugRequiredFor; - /// The object supplied to the [CompositedTransformTarget] that wraps the text - /// field. - final LayerLink toolbarLayerLink; - /// The objects supplied to the [CompositedTransformTarget] that wraps the /// location of start selection handle. final LayerLink startHandleLayerLink; @@ -136,6 +135,11 @@ class EditorTextSelectionOverlay { /// text field. final TextSelectionDelegate selectionDelegate; + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, no context menu will be built. + final WidgetBuilder? contextMenuBuilder; + /// Determines the way that drag start behavior is handled. /// /// If set to [DragStartBehavior.start], handle drag behavior will @@ -169,7 +173,6 @@ class EditorTextSelectionOverlay { /// Useful because the actual value of the clipboard can only be checked /// asynchronously (see [Clipboard.getData]). final ClipboardStatusNotifier clipboardStatus; - late AnimationController _toolbarController; /// A pair of handles. If this is non-null, there are always 2, though the /// second is hidden when the selection is collapsed. @@ -180,8 +183,6 @@ class EditorTextSelectionOverlay { TextSelection get _selection => value.selection; - Animation get _toolbarOpacity => _toolbarController.view; - void setHandlesVisible(bool visible) { if (handlesVisible == visible) { return; @@ -212,7 +213,6 @@ class EditorTextSelectionOverlay { /// To hide the whole overlay, see [hide]. void hideToolbar() { assert(toolbar != null); - _toolbarController.stop(); toolbar!.remove(); toolbar = null; } @@ -220,10 +220,12 @@ class EditorTextSelectionOverlay { /// Shows the toolbar by inserting it into the [context]'s overlay. void showToolbar() { assert(toolbar == null); - toolbar = OverlayEntry(builder: _buildToolbar); + if (contextMenuBuilder == null) return; + toolbar = OverlayEntry(builder: (context) { + return contextMenuBuilder!(context); + }); Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) .insert(toolbar!); - _toolbarController.forward(from: 0); // make sure handles are visible as well if (_handles == null) { @@ -311,63 +313,6 @@ class EditorTextSelectionOverlay { ..bringIntoView(textPosition); } - Widget _buildToolbar(BuildContext context) { - // Find the horizontal midpoint, just above the selected text. - List endpoints; - - try { - // building with an invalid selection with throw an exception - // This happens where the selection has changed, but the toolbar - // hasn't been dismissed yet. - endpoints = renderObject.getEndpointsForSelection(_selection); - } catch (_) { - return Container(); - } - - final editingRegion = Rect.fromPoints( - renderObject.localToGlobal(Offset.zero), - renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)), - ); - - final baseLineHeight = renderObject.preferredLineHeight(_selection.base); - final extentLineHeight = - renderObject.preferredLineHeight(_selection.extent); - final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); - final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > - smallestLineHeight / 2; - - // If the selected text spans more than 1 line, - // horizontally center the toolbar. - // Derived from both iOS and Android. - final midX = isMultiline - ? editingRegion.width / 2 - : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; - - final midpoint = Offset( - midX, - // The y-coordinate won't be made use of most likely. - endpoints[0].point.dy - baseLineHeight, - ); - - return FadeTransition( - opacity: _toolbarOpacity, - child: CompositedTransformFollower( - link: toolbarLayerLink, - showWhenUnlinked: false, - offset: -editingRegion.topLeft, - child: selectionCtrls.buildToolbar( - context, - editingRegion, - baseLineHeight, - midpoint, - endpoints, - selectionDelegate, - clipboardStatus, - null), - ), - ); - } - void markNeedsBuild([Duration? duration]) { if (_handles != null) { _handles![0].markNeedsBuild(); @@ -391,7 +336,6 @@ class EditorTextSelectionOverlay { /// Final cleanup. void dispose() { hide(); - _toolbarController.dispose(); } /// Builds the handles by inserting them into the [context]'s overlay. @@ -706,6 +650,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { this.onDragSelectionUpdate, this.onDragSelectionEnd, this.behavior, + this.detectWordBoundary = true, Key? key, }) : super(key: key); @@ -781,6 +726,8 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { /// Child below this widget. final Widget child; + final bool detectWordBoundary; + @override State createState() => _EditorTextSelectionGestureDetectorState(); diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 5ea2ad18..58171ad1 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -45,8 +45,10 @@ const double kIconButtonFactor = 1.77; class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { const QuillToolbar({ required this.children, - this.toolbarHeight = 36, + this.axis = Axis.horizontal, + this.toolbarSize = 36, this.toolbarIconAlignment = WrapAlignment.center, + this.toolbarIconCrossAlignment = WrapCrossAlignment.center, this.toolbarSectionSpacing = 4, this.multiRowsDisplay = true, this.color, @@ -58,9 +60,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { factory QuillToolbar.basic({ required QuillController controller, + Axis axis = Axis.horizontal, double toolbarIconSize = kDefaultIconSize, double toolbarSectionSpacing = 4, WrapAlignment toolbarIconAlignment = WrapAlignment.center, + WrapCrossAlignment toolbarIconCrossAlignment = WrapCrossAlignment.center, bool showDividers = true, bool showFontFamily = true, bool showFontSize = true, @@ -170,10 +174,12 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { return QuillToolbar( key: key, + axis: axis, color: color, - toolbarHeight: toolbarIconSize * 2, + toolbarSize: toolbarIconSize * 2, toolbarSectionSpacing: toolbarSectionSpacing, toolbarIconAlignment: toolbarIconAlignment, + toolbarIconCrossAlignment: toolbarIconCrossAlignment, multiRowsDisplay: multiRowsDisplay, customButtons: customButtons, locale: locale, @@ -334,11 +340,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + _dividerOnAxis(axis), if (showAlignmentButtons) SelectAlignmentButton( controller: controller, @@ -365,14 +367,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + _dividerOnAxis(axis), if (showHeaderStyle) SelectHeaderStyleButton( controller: controller, + axis: axis, iconSize: toolbarIconSize, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -383,11 +382,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { (isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + _dividerOnAxis(axis), if (showListNumbers) ToggleStyleButton( attribute: Attribute.ol, @@ -427,11 +422,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { if (showDividers && isButtonGroupShown[3] && (isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + _dividerOnAxis(axis), if (showQuote) ToggleStyleButton( attribute: Attribute.blockQuote, @@ -460,11 +451,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { afterButtonPressed: afterButtonPressed, ), if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5]) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + _dividerOnAxis(axis), if (showLink) LinkStyleButton( controller: controller, @@ -483,12 +470,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { afterButtonPressed: afterButtonPressed, ), if (customButtons.isNotEmpty) - if (showDividers) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + if (showDividers) _dividerOnAxis(axis), for (var customButton in customButtons) QuillIconButton( highlightElevation: 0, @@ -503,10 +485,28 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ); } + static Widget _dividerOnAxis(Axis axis) { + if (axis == Axis.horizontal) { + return const VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey, + ); + } else { + return const Divider( + indent: 12, + endIndent: 12, + color: Colors.grey, + ); + } + } + final List children; - final double toolbarHeight; + final Axis axis; + final double toolbarSize; final double toolbarSectionSpacing; final WrapAlignment toolbarIconAlignment; + final WrapCrossAlignment toolbarIconCrossAlignment; final bool multiRowsDisplay; /// The color of the toolbar. @@ -523,7 +523,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { final List customButtons; @override - Size get preferredSize => Size.fromHeight(toolbarHeight); + Size get preferredSize => axis == Axis.horizontal + ? Size.fromHeight(toolbarSize) + : Size.fromWidth(toolbarSize); @override Widget build(BuildContext context) { @@ -531,16 +533,23 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { initialLocale: locale, child: multiRowsDisplay ? Wrap( + direction: axis, alignment: toolbarIconAlignment, + crossAxisAlignment: toolbarIconCrossAlignment, runSpacing: 4, spacing: toolbarSectionSpacing, children: children, ) : Container( - constraints: - BoxConstraints.tightFor(height: preferredSize.height), + constraints: BoxConstraints.tightFor( + height: axis == Axis.horizontal ? toolbarSize : null, + width: axis == Axis.vertical ? toolbarSize : null, + ), color: color ?? Theme.of(context).canvasColor, - child: ArrowIndicatedButtonList(buttons: children), + child: ArrowIndicatedButtonList( + axis: axis, + buttons: children, + ), ), ); } diff --git a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart index 1c6917a1..84f19854 100644 --- a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart +++ b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart @@ -7,9 +7,13 @@ import 'package:flutter/material.dart'; /// The arrow indicators are automatically hidden if the list is not /// scrollable in the direction of the respective arrow. class ArrowIndicatedButtonList extends StatefulWidget { - const ArrowIndicatedButtonList({required this.buttons, Key? key}) - : super(key: key); + const ArrowIndicatedButtonList({ + required this.axis, + required this.buttons, + Key? key, + }) : super(key: key); + final Axis axis; final List buttons; @override @@ -20,8 +24,8 @@ class ArrowIndicatedButtonList extends StatefulWidget { class _ArrowIndicatedButtonListState extends State with WidgetsBindingObserver { final ScrollController _controller = ScrollController(); - bool _showLeftArrow = false; - bool _showRightArrow = false; + bool _showBackwardArrow = false; + bool _showForwardArrow = false; @override void initState() { @@ -40,13 +44,19 @@ class _ArrowIndicatedButtonListState extends State @override Widget build(BuildContext context) { - return Row( - children: [ - _buildLeftArrow(), - _buildScrollableList(), - _buildRightColor(), - ], - ); + final children = [ + _buildBackwardArrow(), + _buildScrollableList(), + _buildForwardArrow(), + ]; + + return widget.axis == Axis.horizontal + ? Row( + children: children, + ) + : Column( + children: children, + ); } @override @@ -63,20 +73,29 @@ class _ArrowIndicatedButtonListState extends State if (!mounted) return; setState(() { - _showLeftArrow = + _showBackwardArrow = _controller.position.minScrollExtent != _controller.position.pixels; - _showRightArrow = + _showForwardArrow = _controller.position.maxScrollExtent != _controller.position.pixels; }); } - Widget _buildLeftArrow() { + Widget _buildBackwardArrow() { + IconData? icon; + if (_showBackwardArrow) { + if (widget.axis == Axis.horizontal) { + icon = Icons.arrow_left; + } else { + icon = Icons.arrow_drop_up; + } + } + return SizedBox( width: 8, child: Transform.translate( // Move the icon a few pixels to center it offset: const Offset(-5, 0), - child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null, + child: icon != null ? Icon(icon, size: 18) : null, ), ); } @@ -87,18 +106,24 @@ class _ArrowIndicatedButtonListState extends State // Remove the glowing effect, as we already have the arrow indicators behavior: _NoGlowBehavior(), // The CustomScrollView is necessary so that the children are not - // stretched to the height of the toolbar, https://bit.ly/3uC3bjI + // stretched to the height of the toolbar: + // https://stackoverflow.com/a/65998731/7091839 child: CustomScrollView( - scrollDirection: Axis.horizontal, + scrollDirection: widget.axis, controller: _controller, physics: const ClampingScrollPhysics(), slivers: [ SliverFillRemaining( hasScrollBody: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: widget.buttons, - ), + child: widget.axis == Axis.horizontal + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widget.buttons, + ) + : Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widget.buttons, + ), ) ], ), @@ -106,13 +131,22 @@ class _ArrowIndicatedButtonListState extends State ); } - Widget _buildRightColor() { + Widget _buildForwardArrow() { + IconData? icon; + if (_showForwardArrow) { + if (widget.axis == Axis.horizontal) { + icon = Icons.arrow_right; + } else { + icon = Icons.arrow_drop_down; + } + } + return SizedBox( width: 8, child: Transform.translate( // Move the icon a few pixels to center it offset: const Offset(-5, 0), - child: _showRightArrow ? const Icon(Icons.arrow_right, size: 18) : null, + child: icon != null ? Icon(icon, size: 18) : null, ), ); } diff --git a/lib/src/widgets/toolbar/select_header_style_button.dart b/lib/src/widgets/toolbar/select_header_style_button.dart index 72192398..f27998b8 100644 --- a/lib/src/widgets/toolbar/select_header_style_button.dart +++ b/lib/src/widgets/toolbar/select_header_style_button.dart @@ -10,6 +10,7 @@ import '../toolbar.dart'; class SelectHeaderStyleButton extends StatefulWidget { const SelectHeaderStyleButton({ required this.controller, + this.axis = Axis.horizontal, this.iconSize = kDefaultIconSize, this.iconTheme, this.attributes = const [ @@ -23,6 +24,7 @@ class SelectHeaderStyleButton extends StatefulWidget { }) : super(key: key); final QuillController controller; + final Axis axis; final double iconSize; final QuillIconTheme? iconTheme; final List attributes; @@ -67,53 +69,60 @@ class _SelectHeaderStyleButtonState extends State { fontSize: widget.iconSize * 0.7, ); - return Row( - mainAxisSize: MainAxisSize.min, - children: widget.attributes.map((attribute) { - final isSelected = _selectedAttribute == attribute; - return Padding( - // ignore: prefer_const_constructors - padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), - child: ConstrainedBox( - constraints: BoxConstraints.tightFor( - width: widget.iconSize * kIconButtonFactor, - height: widget.iconSize * kIconButtonFactor, - ), - child: RawMaterialButton( - hoverElevation: 0, - highlightElevation: 0, - elevation: 0, - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - widget.iconTheme?.borderRadius ?? 2)), - fillColor: isSelected - ? (widget.iconTheme?.iconSelectedFillColor ?? - Theme.of(context).primaryColor) - : (widget.iconTheme?.iconUnselectedFillColor ?? - theme.canvasColor), - onPressed: () { - final _attribute = _selectedAttribute == attribute - ? Attribute.header - : attribute; - widget.controller.formatSelection(_attribute); - widget.afterButtonPressed?.call(); - }, - child: Text( - _valueToText[attribute] ?? '', - style: style.copyWith( - color: isSelected - ? (widget.iconTheme?.iconSelectedColor ?? - theme.primaryIconTheme.color) - : (widget.iconTheme?.iconUnselectedColor ?? - theme.iconTheme.color), - ), + final children = widget.attributes.map((attribute) { + final isSelected = _selectedAttribute == attribute; + return Padding( + // ignore: prefer_const_constructors + padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: widget.iconSize * kIconButtonFactor, + height: widget.iconSize * kIconButtonFactor, + ), + child: RawMaterialButton( + hoverElevation: 0, + highlightElevation: 0, + elevation: 0, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(widget.iconTheme?.borderRadius ?? 2)), + fillColor: isSelected + ? (widget.iconTheme?.iconSelectedFillColor ?? + Theme.of(context).primaryColor) + : (widget.iconTheme?.iconUnselectedFillColor ?? + theme.canvasColor), + onPressed: () { + final _attribute = _selectedAttribute == attribute + ? Attribute.header + : attribute; + widget.controller.formatSelection(_attribute); + widget.afterButtonPressed?.call(); + }, + child: Text( + _valueToText[attribute] ?? '', + style: style.copyWith( + color: isSelected + ? (widget.iconTheme?.iconSelectedColor ?? + theme.primaryIconTheme.color) + : (widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color), ), ), ), - ); - }).toList(), - ); + ), + ); + }).toList(); + + return widget.axis == Axis.horizontal + ? Row( + mainAxisSize: MainAxisSize.min, + children: children, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); } void _didChangeEditingValue() { diff --git a/pubspec.yaml b/pubspec.yaml index 68f4cdfd..06f8fa10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_quill description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 6.3.1 +version: 6.4.4 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill @@ -12,17 +12,17 @@ environment: dependencies: flutter: sdk: flutter - collection: ^1.16.0 + collection: ^1.17.0 flutter_colorpicker: ^1.0.3 - flutter_keyboard_visibility: ^5.2.0 - quiver: ^3.1.0 - tuple: ^2.0.0 - url_launcher: ^6.1.2 + flutter_keyboard_visibility: ^5.4.0 + quiver: ^3.2.1 + tuple: ^2.0.1 + url_launcher: ^6.1.9 pedantic: ^1.11.1 - characters: ^1.2.0 + characters: ^1.2.1 diff_match_patch: ^0.4.1 - i18n_extension: ^6.0.0 - device_info_plus: ^8.0.0 + i18n_extension: ^7.0.0 + device_info_plus: ^8.1.0 platform: ^3.1.0 pasteboard: ^0.2.0