diff --git a/.gitignore b/.gitignore index 6b87759f..31a70ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ example/ios/Podfile.lock !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 pubspec.lock + +# For local development +pubspec_overrides.yaml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index eac64dd2..9383c4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [8.2.6] +- Organize `QuillRawEditor` code + ## [8.2.5] - Add `builder` property in the `QuillEditorConfigurations` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 36907521..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing - -We welcome contributions! \ No newline at end of file diff --git a/README.md b/README.md index 472313f9..7d2892e2 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Made with [contrib.rocks](https://contrib.rocks). We welcome contributions! -Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. +Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./doc/CONTRIBUTING.md) for more details. [Quill]: https://quilljs.com/docs/formats [Flutter]: https://github.com/flutter/flutter diff --git a/doc/CONTRIBUTING.md b/doc/CONTRIBUTING.md new file mode 100644 index 00000000..f6f8d9d9 --- /dev/null +++ b/doc/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing + +The contributions are more than welcome!
+This project will be better with the open-source community help + +There are no guidelines for now. +This page will be updated in the future. + +## Steps to contributing + +You will need GitHub account as well as git installed and configured with your GitHub account on your machine + +1. Fork the repository in GitHub +2. clone the forked repository using `git` +3. Add the `upstream` repository using: + ``` + git remote add upstream git@github.com:singerdmx/flutter-quill.git + ``` +4. Open the project with your favorite IDE, we suggest using [IntelliJ IDEA Community Edition](https://www.jetbrains.com/idea/download/) +5. Sync the project with Gradle +6. Create a new git branch and switch to it using: + + ``` + git checkout -b your-branch-name + ``` + The `your-branch-name` is your choice +7. Make your changes +8. If you are working on changes that depend on different library in the same repo, then in that directory copy `pubspec_overrides.yaml.g` which exists in all the libraries (`flutter_quill_test` and `flutter_quill_extensions` etc..) +to `pubspec_overrides.yaml` which will be ignored by `.gitignore` and it will be used by dart pub to override the libraries + ``` + cp pubspec_overrides.yaml.g pubspec_overrides.yaml + ``` + or save some time and the following script: + ``` + ./scripts/enable_local_dev.sh + ``` +10. Test them in the [example](../example) and add changes in there if necessary +11. Mention the new changes in the [CHANGELOG.md](../CHANGELOG.md) in the next block +12. Run the following script if possible + ``` + ./scripts/before-push.sh + ``` +13. When you are done to send your pull request, run: + ``` + git add . + git commit -m "Your commit message" + git push origin your-branch-name + ``` + this will push the new branch to your forked repository +14. Now you can send your pull request either by following the link that you will get in the command line or open your +forked repository, and you will find an option to send the pull request, you can also +open the [Pull Requests](https://github.com/singerdmx/flutter-quill) tab and send new pull request +1. Please wait for the review, and we might ask you to make more changes, then run: +``` +git add . +git commit -m "Your new commit message" +git push origin your-branch-name +``` + +Thank you for your time and efforts to this open-source community project!! \ No newline at end of file diff --git a/flutter_quill_extensions/README.md b/flutter_quill_extensions/README.md index bbb75671..d85e0f32 100644 --- a/flutter_quill_extensions/README.md +++ b/flutter_quill_extensions/README.md @@ -241,7 +241,7 @@ OnDragDoneCallback get _onDragDone { We welcome contributions! -Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./../CONTRIBUTING.md) for more details. +Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./../doc/CONTRIBUTING.md) for more details. ## License diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml index 4a88c192..04fce298 100644 --- a/flutter_quill_extensions/pubspec.yaml +++ b/flutter_quill_extensions/pubspec.yaml @@ -42,13 +42,6 @@ dependencies: meta: ^1.9.1 cross_file: ^0.3.3+6 -# In case you are working on changes for both libraries -# Comment the dependency_overrides section when publishing the package, -# then uncomment it back, this will be automated later -dependency_overrides: - flutter_quill: - path: ../ - dev_dependencies: flutter_test: sdk: flutter diff --git a/flutter_quill_extensions/pubspec_overrides.yaml.g b/flutter_quill_extensions/pubspec_overrides.yaml.g new file mode 100644 index 00000000..5593142e --- /dev/null +++ b/flutter_quill_extensions/pubspec_overrides.yaml.g @@ -0,0 +1,3 @@ +dependency_overrides: + flutter_quill: + path: ../ \ No newline at end of file diff --git a/flutter_quill_test/README.md b/flutter_quill_test/README.md index ea602306..f18ad9ca 100644 --- a/flutter_quill_test/README.md +++ b/flutter_quill_test/README.md @@ -38,7 +38,7 @@ await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); We welcome contributions! -Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./../CONTRIBUTING.md) for more details. +Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./../doc/CONTRIBUTING.md) for more details. ## License diff --git a/flutter_quill_test/pubspec.yaml b/flutter_quill_test/pubspec.yaml index 6212f397..1cb77744 100644 --- a/flutter_quill_test/pubspec.yaml +++ b/flutter_quill_test/pubspec.yaml @@ -26,20 +26,12 @@ environment: dependencies: flutter: sdk: flutter - flutter_quill: ^8.2.4 + flutter_quill: ^8.2.5 flutter_test: sdk: flutter dev_dependencies: flutter_lints: ^3.0.1 -# In case you are working on changes for both libraries -# Comment the dependency_overrides section when publishing the package, -# then uncomment it back, this will be automated later -# dependency_overrides: -# flutter_quill: -# path: ../ - - flutter: uses-material-design: true diff --git a/flutter_quill_test/pubspec_overrides.yaml.g b/flutter_quill_test/pubspec_overrides.yaml.g new file mode 100644 index 00000000..5593142e --- /dev/null +++ b/flutter_quill_test/pubspec_overrides.yaml.g @@ -0,0 +1,3 @@ +dependency_overrides: + flutter_quill: + path: ../ \ No newline at end of file diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 7f5f6459..a65afc7a 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -28,6 +28,7 @@ export 'src/widgets/editor/editor.dart'; export 'src/widgets/embeds.dart'; export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction; export 'src/widgets/raw_editor/raw_editor.dart'; +export 'src/widgets/raw_editor/raw_editor_state.dart'; export 'src/widgets/style_widgets/style_widgets.dart'; export 'src/widgets/toolbar/base_toolbar.dart'; export 'src/widgets/toolbar/toolbar.dart'; diff --git a/lib/src/widgets/editor/editor.dart b/lib/src/widgets/editor/editor.dart index c598fec7..9d20a552 100644 --- a/lib/src/widgets/editor/editor.dart +++ b/lib/src/widgets/editor/editor.dart @@ -1,8 +1,9 @@ import 'dart:math' as math; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; +import 'package:flutter/cupertino.dart' + show CupertinoTheme, cupertinoTextSelectionControls; +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; diff --git a/lib/src/widgets/raw_editor/raw_editor.dart b/lib/src/widgets/raw_editor/raw_editor.dart index f289ca54..55b9a590 100644 --- a/lib/src/widgets/raw_editor/raw_editor.dart +++ b/lib/src/widgets/raw_editor/raw_editor.dart @@ -1,59 +1,13 @@ -import 'dart:async' show StreamSubscription; -import 'dart:convert' show jsonDecode; -import 'dart:math' as math; -import 'dart:ui' as ui hide TextStyle; - -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' - show RenderAbstractViewport, ViewportOffset; -import 'package:flutter/scheduler.dart' show SchedulerBinding; -import 'package:flutter/services.dart' - show - LogicalKeyboardKey, - Uint8List, - RawKeyDownEvent, - HardwareKeyboard, - Clipboard, - ClipboardData, - TextLayoutMetrics, - TextInputControl; -import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart' - show KeyboardVisibilityController; -import 'package:pasteboard/pasteboard.dart' show Pasteboard; +import 'package:flutter/services.dart' show Uint8List; -import '../../models/documents/attribute.dart'; -import '../../models/documents/document.dart'; -import '../../models/documents/nodes/block.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/documents/nodes/leaf.dart' as leaf; -import '../../models/documents/nodes/line.dart'; -import '../../models/documents/nodes/node.dart'; -import '../../models/structs/offset_value.dart'; -import '../../models/structs/vertical_spacing.dart'; import '../../models/themes/quill_dialog_theme.dart'; -import '../../utils/cast.dart'; -import '../../utils/delta.dart'; -import '../../utils/embeds.dart'; -import '../../utils/extensions/build_context.dart'; -import '../../utils/platform.dart'; import '../controller.dart'; import '../cursor.dart'; import '../default_styles.dart'; import '../delegate.dart'; -import '../editor/editor.dart'; -import '../keyboard_listener.dart'; import '../link.dart'; -import '../proxy.dart'; -import '../quill_single_child_scroll_view.dart'; -import '../text_block.dart'; -import '../text_line.dart'; -import '../text_selection.dart'; -import '../toolbar/buttons/link_style2.dart'; -import '../toolbar/buttons/search/search_dialog.dart'; -import 'raw_editor_state_selection_delegate_mixin.dart'; -import 'raw_editor_state_text_input_client_mixin.dart'; +import 'raw_editor_state.dart'; class QuillRawEditor extends StatefulWidget { const QuillRawEditor({ @@ -291,2486 +245,6 @@ class QuillRawEditor extends StatefulWidget { State createState() => QuillRawEditorState(); } -class QuillRawEditorState extends EditorState - with - AutomaticKeepAliveClientMixin, - WidgetsBindingObserver, - TickerProviderStateMixin, - RawEditorStateTextInputClientMixin, - RawEditorStateSelectionDelegateMixin { - final GlobalKey _editorKey = GlobalKey(); - - KeyboardVisibilityController? _keyboardVisibilityController; - StreamSubscription? _keyboardVisibilitySubscription; - bool _keyboardVisible = false; - - // Selection overlay - @override - EditorTextSelectionOverlay? get selectionOverlay => _selectionOverlay; - EditorTextSelectionOverlay? _selectionOverlay; - - @override - ScrollController get scrollController => _scrollController; - late ScrollController _scrollController; - - // Cursors - late CursorCont _cursorCont; - - QuillController get controller => widget.controller; - - // Focus - bool _didAutoFocus = false; - - bool get _hasFocus => widget.focusNode.hasFocus; - - // Theme - DefaultStyles? _styles; - - // for pasting style - @override - List get pasteStyleAndEmbed => _pasteStyleAndEmbed; - List _pasteStyleAndEmbed = []; - - @override - String get pastePlainText => _pastePlainText; - String _pastePlainText = ''; - - final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); - final LayerLink _toolbarLayerLink = LayerLink(); - final LayerLink _startHandleLayerLink = LayerLink(); - final LayerLink _endHandleLayerLink = LayerLink(); - - TextDirection get _textDirection => Directionality.of(context); - - @override - bool get dirty => _dirty; - bool _dirty = false; - - @override - void insertContent(KeyboardInsertedContent content) { - assert(widget.contentInsertionConfiguration?.allowedMimeTypes - .contains(content.mimeType) ?? - false); - widget.contentInsertionConfiguration?.onContentInserted.call(content); - } - - /// Returns the [ContextMenuButtonItem]s representing the buttons in this - /// platform's default selection menu for [QuillRawEditor]. - /// - /// Copied from [EditableTextState]. - List get contextMenuButtonItems { - return EditableText.getEditableButtonItems( - clipboardStatus: _clipboardStatus.value, - onLiveTextInput: null, - 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.startGlyphHeight, - endGlyphHeight: glyphHeights.endGlyphHeight, - selectionEndpoints: points, - ); - } - - /// Gets the line heights at the start and end of the selection for the given - /// [QuillRawEditorState]. - /// - /// Copied from [EditableTextState]. - _GlyphHeights _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 _GlyphHeights( - renderEditor.preferredLineHeight(selection.base), - renderEditor.preferredLineHeight(selection.base), - ); - } - - final startCharacterRect = - renderEditor.getLocalRectForCaret(selection.base); - final endCharacterRect = - renderEditor.getLocalRectForCaret(selection.extent); - return _GlyphHeights( - startCharacterRect.height, - endCharacterRect.height, - ); - } - - void _defaultOnTapOutside(PointerDownEvent event) { - if (isWeb()) { - widget.focusNode.unfocus(); - } - - /// The focus dropping behavior is only present on desktop platforms - /// and mobile browsers. - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.iOS: - case TargetPlatform.fuchsia: - // On mobile platforms, we don't unfocus on touch events unless they're - // in the web browser, but we do unfocus for all other kinds of events. - switch (event.kind) { - case ui.PointerDeviceKind.touch: - break; - case ui.PointerDeviceKind.mouse: - case ui.PointerDeviceKind.stylus: - case ui.PointerDeviceKind.invertedStylus: - case ui.PointerDeviceKind.unknown: - widget.focusNode.unfocus(); - break; - case ui.PointerDeviceKind.trackpad: - throw UnimplementedError( - 'Unexpected pointer down event for trackpad.', - ); - } - break; - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - widget.focusNode.unfocus(); - break; - default: - throw UnsupportedError( - 'The platform ${defaultTargetPlatform.name} is not supported in the' - ' _defaultOnTapOutside()', - ); - } - } - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - super.build(context); - - var doc = controller.document; - if (doc.isEmpty() && widget.placeholder != null) { - final raw = widget.placeholder?.replaceAll(r'"', '\\"'); - doc = Document.fromJson( - jsonDecode( - '[{"attributes":{"placeholder":true},"insert":"$raw\\n"}]', - ), - ); - } - - Widget child = CompositedTransformTarget( - link: _toolbarLayerLink, - child: Semantics( - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: _Editor( - key: _editorKey, - document: doc, - selection: controller.selection, - hasFocus: _hasFocus, - scrollable: widget.scrollable, - cursorController: _cursorCont, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - onSelectionCompleted: _handleSelectionCompleted, - scrollBottomInset: widget.scrollBottomInset, - padding: widget.padding, - maxContentWidth: widget.maxContentWidth, - floatingCursorDisabled: widget.floatingCursorDisabled, - children: _buildChildren(doc, context), - ), - ), - ), - ); - - if (widget.scrollable) { - /// Since [SingleChildScrollView] does not implement - /// `computeDistanceToActualBaseline` it prevents the editor from - /// providing its baseline metrics. To address this issue we wrap - /// the scroll view with [BaselineProxy] which mimics the editor's - /// baseline. - // This implies that the first line has no styles applied to it. - final baselinePadding = - EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.top); - child = BaselineProxy( - textStyle: _styles!.paragraph!.style, - padding: baselinePadding, - child: QuillSingleChildScrollView( - controller: _scrollController, - physics: widget.scrollPhysics, - viewportBuilder: (_, offset) => CompositedTransformTarget( - link: _toolbarLayerLink, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: _Editor( - key: _editorKey, - offset: offset, - document: doc, - selection: controller.selection, - hasFocus: _hasFocus, - scrollable: widget.scrollable, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - onSelectionCompleted: _handleSelectionCompleted, - scrollBottomInset: widget.scrollBottomInset, - padding: widget.padding, - maxContentWidth: widget.maxContentWidth, - cursorController: _cursorCont, - floatingCursorDisabled: widget.floatingCursorDisabled, - children: _buildChildren(doc, context), - ), - ), - ), - ), - ); - } - - final constraints = widget.expands - ? const BoxConstraints.expand() - : BoxConstraints( - minHeight: widget.minHeight ?? 0.0, - maxHeight: widget.maxHeight ?? double.infinity, - ); - - // Please notice that this change will make the check fixed - // so if we ovveride the platform in material app theme data - // it will not depend on it and doesn't change here but I don't think - // we need to - final isDesktopMacOS = isMacOS(); - - return TextFieldTapRegion( - enabled: widget.enableUnfocusOnTapOutside, - onTapOutside: (event) { - final onTapOutside = - context.requireQuillEditorConfigurations.onTapOutside; - if (onTapOutside != null) { - context.requireQuillEditorConfigurations.onTapOutside - ?.call(event, widget.focusNode); - return; - } - _defaultOnTapOutside(event); - }, - child: QuillStyles( - data: _styles!, - child: Shortcuts( - shortcuts: mergeMaps({ - // shortcuts added for Desktop platforms. - const SingleActivator( - LogicalKeyboardKey.escape, - ): const HideSelectionToolbarIntent(), - SingleActivator( - LogicalKeyboardKey.keyZ, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const UndoTextIntent(SelectionChangedCause.keyboard), - SingleActivator( - LogicalKeyboardKey.keyY, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const RedoTextIntent(SelectionChangedCause.keyboard), - - // Selection formatting. - SingleActivator( - LogicalKeyboardKey.keyB, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ToggleTextStyleIntent(Attribute.bold), - SingleActivator( - LogicalKeyboardKey.keyU, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ToggleTextStyleIntent(Attribute.underline), - SingleActivator( - LogicalKeyboardKey.keyI, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ToggleTextStyleIntent(Attribute.italic), - SingleActivator( - LogicalKeyboardKey.keyS, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const ToggleTextStyleIntent(Attribute.strikeThrough), - SingleActivator( - LogicalKeyboardKey.backquote, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ToggleTextStyleIntent(Attribute.inlineCode), - SingleActivator( - LogicalKeyboardKey.tilde, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const ToggleTextStyleIntent(Attribute.codeBlock), - SingleActivator( - LogicalKeyboardKey.keyB, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const ToggleTextStyleIntent(Attribute.blockQuote), - SingleActivator( - LogicalKeyboardKey.keyK, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ApplyLinkIntent(), - - // Lists - SingleActivator( - LogicalKeyboardKey.keyL, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const ToggleTextStyleIntent(Attribute.ul), - SingleActivator( - LogicalKeyboardKey.keyO, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const ToggleTextStyleIntent(Attribute.ol), - SingleActivator( - LogicalKeyboardKey.keyC, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const ApplyCheckListIntent(), - - // Indents - SingleActivator( - LogicalKeyboardKey.keyM, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const IndentSelectionIntent(true), - SingleActivator( - LogicalKeyboardKey.keyM, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - shift: true, - ): const IndentSelectionIntent(false), - - // Headers - SingleActivator( - LogicalKeyboardKey.digit1, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ApplyHeaderIntent(Attribute.h1), - SingleActivator( - LogicalKeyboardKey.digit2, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ApplyHeaderIntent(Attribute.h2), - SingleActivator( - LogicalKeyboardKey.digit3, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ApplyHeaderIntent(Attribute.h3), - SingleActivator( - LogicalKeyboardKey.digit0, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const ApplyHeaderIntent(Attribute.header), - - SingleActivator( - LogicalKeyboardKey.keyG, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const InsertEmbedIntent(Attribute.image), - - SingleActivator( - LogicalKeyboardKey.keyF, - control: !isDesktopMacOS, - meta: isDesktopMacOS, - ): const OpenSearchIntent(), - }, { - ...?widget.customShortcuts - }), - child: Actions( - actions: mergeMaps>(_actions, { - ...?widget.customActions, - }), - child: Focus( - focusNode: widget.focusNode, - onKey: _onKey, - child: QuillKeyboardListener( - child: Container( - constraints: constraints, - child: child, - ), - ), - ), - ), - ), - ), - ); - } - - KeyEventResult _onKey(node, RawKeyEvent event) { - // Don't handle key if there is a meta key pressed. - if (event.isAltPressed || event.isControlPressed || event.isMetaPressed) { - return KeyEventResult.ignored; - } - - if (event is! RawKeyDownEvent) { - return KeyEventResult.ignored; - } - // Handle indenting blocks when pressing the tab key. - if (event.logicalKey == LogicalKeyboardKey.tab) { - return _handleTabKey(event); - } - - // Don't handle key if there is an active selection. - if (controller.selection.baseOffset != controller.selection.extentOffset) { - return KeyEventResult.ignored; - } - - // Handle inserting lists when space is pressed following - // a list initiating phrase. - if (event.logicalKey == LogicalKeyboardKey.space) { - return _handleSpaceKey(event); - } - - return KeyEventResult.ignored; - } - - KeyEventResult _handleSpaceKey(RawKeyEvent event) { - final child = - controller.document.queryChild(controller.selection.baseOffset); - if (child.node == null) { - return KeyEventResult.ignored; - } - - final line = child.node as Line?; - if (line == null) { - return KeyEventResult.ignored; - } - - final text = castOrNull(line.first); - if (text == null) { - return KeyEventResult.ignored; - } - - const olKeyPhrase = '1.'; - const ulKeyPhrase = '-'; - - if (text.value == olKeyPhrase) { - _updateSelectionForKeyPhrase(olKeyPhrase, Attribute.ol); - } else if (text.value == ulKeyPhrase) { - _updateSelectionForKeyPhrase(ulKeyPhrase, Attribute.ul); - } else { - return KeyEventResult.ignored; - } - - return KeyEventResult.handled; - } - - KeyEventResult _handleTabKey(RawKeyEvent event) { - final child = - controller.document.queryChild(controller.selection.baseOffset); - - KeyEventResult insertTabCharacter() { - if (widget.readOnly) { - return KeyEventResult.ignored; - } - controller.replaceText(controller.selection.baseOffset, 0, '\t', null); - _moveCursor(1); - return KeyEventResult.handled; - } - - if (controller.selection.baseOffset != controller.selection.extentOffset) { - if (child.node == null || child.node!.parent == null) { - return KeyEventResult.handled; - } - final parentBlock = child.node!.parent!; - if (parentBlock.style.containsKey(Attribute.ol.key) || - parentBlock.style.containsKey(Attribute.ul.key) || - parentBlock.style.containsKey(Attribute.checked.key)) { - controller.indentSelection(!event.isShiftPressed); - } - return KeyEventResult.handled; - } - - if (child.node == null) { - return insertTabCharacter(); - } - - final node = child.node!; - - final parent = node.parent; - if (parent == null || parent is! Block) { - return insertTabCharacter(); - } - - if (node is! Line || (node.isNotEmpty && node.first is! leaf.QuillText)) { - return insertTabCharacter(); - } - - final parentBlock = parent; - if (parentBlock.style.containsKey(Attribute.ol.key) || - parentBlock.style.containsKey(Attribute.ul.key) || - parentBlock.style.containsKey(Attribute.checked.key)) { - if (node.isNotEmpty && - (node.first as leaf.QuillText).value.isNotEmpty && - controller.selection.base.offset > node.documentOffset) { - return insertTabCharacter(); - } - controller.indentSelection(!event.isShiftPressed); - return KeyEventResult.handled; - } - - if (node.isNotEmpty && (node.first as leaf.QuillText).value.isNotEmpty) { - return insertTabCharacter(); - } - - return insertTabCharacter(); - } - - void _moveCursor(int chars) { - final selection = controller.selection; - controller.updateSelection( - controller.selection.copyWith( - baseOffset: selection.baseOffset + chars, - extentOffset: selection.baseOffset + chars), - ChangeSource.local); - } - - void _updateSelectionForKeyPhrase(String phrase, Attribute attribute) { - controller.replaceText(controller.selection.baseOffset - phrase.length, - phrase.length, '\n', null); - _moveCursor(-phrase.length); - controller - ..formatSelection(attribute) - // Remove the added newline. - ..replaceText(controller.selection.baseOffset + 1, 1, '', null); - } - - void _handleSelectionChanged( - TextSelection selection, - SelectionChangedCause cause, - ) { - final oldSelection = controller.selection; - controller.updateSelection(selection, ChangeSource.local); - - _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); - - if (!_keyboardVisible) { - // This will show the keyboard for all selection changes on the - // editor, not just changes triggered by user gestures. - requestKeyboard(); - } - - if (cause == SelectionChangedCause.drag) { - // When user updates the selection while dragging make sure to - // bring the updated position (base or extent) into view. - if (oldSelection.baseOffset != selection.baseOffset) { - bringIntoView(selection.base); - } else if (oldSelection.extentOffset != selection.extentOffset) { - bringIntoView(selection.extent); - } - } - } - - void _handleSelectionCompleted() { - controller.onSelectionCompleted?.call(); - } - - /// Updates the checkbox positioned at [offset] in document - /// by changing its attribute according to [value]. - void _handleCheckboxTap(int offset, bool value) { - final requestKeyboardFocusOnCheckListChanged = context - .requireQuillEditorConfigurations - .requestKeyboardFocusOnCheckListChanged; - if (!widget.readOnly) { - _disableScrollControllerAnimateOnce = true; - final currentSelection = controller.selection.copyWith(); - final attribute = value ? Attribute.checked : Attribute.unchecked; - - _markNeedsBuild(); - controller - ..ignoreFocusOnTextChange = true - ..skipRequestKeyboard = !requestKeyboardFocusOnCheckListChanged - ..formatText(offset, 0, attribute) - - // Checkbox tapping causes controller.selection to go to offset 0 - // Stop toggling those two toolbar buttons - ..toolbarButtonToggler = { - Attribute.list.key: attribute, - Attribute.header.key: Attribute.header - }; - - // Go back from offset 0 to current selection - SchedulerBinding.instance.addPostFrameCallback((_) { - controller - ..ignoreFocusOnTextChange = false - ..skipRequestKeyboard = !requestKeyboardFocusOnCheckListChanged - ..updateSelection(currentSelection, ChangeSource.local); - }); - } - } - - List _buildChildren(Document doc, BuildContext context) { - final result = []; - final indentLevelCounts = {}; - // this need for several ordered list in document - // we need to reset indents Map, if list finished - // List finished when there is node without Attribute.ol in styles - // So in this case we set clearIndents=true and send it - // to the next EditableTextBlock - var prevNodeOl = false; - var clearIndents = false; - - for (final node in doc.root.children) { - final attrs = node.style.attributes; - - if (prevNodeOl && attrs[Attribute.list.key] != Attribute.ol) { - clearIndents = true; - } - - prevNodeOl = attrs[Attribute.list.key] == Attribute.ol; - - if (node is Line) { - final editableTextLine = _getEditableTextLineFromNode(node, context); - result.add(Directionality( - textDirection: getDirectionOfNode(node), child: editableTextLine)); - } else if (node is Block) { - final editableTextBlock = EditableTextBlock( - block: node, - controller: controller, - textDirection: getDirectionOfNode(node), - scrollBottomInset: widget.scrollBottomInset, - verticalSpacing: _getVerticalSpacingForBlock(node, _styles), - textSelection: controller.selection, - color: widget.selectionColor, - styles: _styles, - enableInteractiveSelection: widget.enableInteractiveSelection, - hasFocus: _hasFocus, - contentPadding: attrs.containsKey(Attribute.codeBlock.key) - ? const EdgeInsets.all(16) - : null, - embedBuilder: widget.embedBuilder, - linkActionPicker: _linkActionPicker, - onLaunchUrl: widget.onLaunchUrl, - cursorCont: _cursorCont, - indentLevelCounts: indentLevelCounts, - clearIndents: clearIndents, - onCheckboxTap: _handleCheckboxTap, - readOnly: widget.readOnly, - customStyleBuilder: widget.customStyleBuilder, - customLinkPrefixes: widget.customLinkPrefixes, - ); - result.add( - Directionality( - textDirection: getDirectionOfNode(node), - child: editableTextBlock, - ), - ); - - clearIndents = false; - } else { - _dirty = false; - throw StateError('Unreachable.'); - } - } - _dirty = false; - return result; - } - - EditableTextLine _getEditableTextLineFromNode( - Line node, BuildContext context) { - final textLine = TextLine( - line: node, - textDirection: _textDirection, - embedBuilder: widget.embedBuilder, - customStyleBuilder: widget.customStyleBuilder, - customRecognizerBuilder: widget.customRecognizerBuilder, - styles: _styles!, - readOnly: widget.readOnly, - controller: controller, - linkActionPicker: _linkActionPicker, - onLaunchUrl: widget.onLaunchUrl, - customLinkPrefixes: widget.customLinkPrefixes, - ); - final editableTextLine = EditableTextLine( - node, - null, - textLine, - 0, - _getVerticalSpacingForLine(node, _styles), - _textDirection, - controller.selection, - widget.selectionColor, - widget.enableInteractiveSelection, - _hasFocus, - MediaQuery.devicePixelRatioOf(context), - _cursorCont); - return editableTextLine; - } - - VerticalSpacing _getVerticalSpacingForLine( - Line line, - DefaultStyles? defaultStyles, - ) { - final attrs = line.style.attributes; - if (attrs.containsKey(Attribute.header.key)) { - int level; - if (attrs[Attribute.header.key]!.value is double) { - level = attrs[Attribute.header.key]!.value.toInt(); - } else { - level = attrs[Attribute.header.key]!.value; - } - switch (level) { - case 1: - return defaultStyles!.h1!.verticalSpacing; - case 2: - return defaultStyles!.h2!.verticalSpacing; - case 3: - return defaultStyles!.h3!.verticalSpacing; - default: - throw ArgumentError('Invalid level $level'); - } - } - - return defaultStyles!.paragraph!.verticalSpacing; - } - - VerticalSpacing _getVerticalSpacingForBlock( - Block node, DefaultStyles? defaultStyles) { - final attrs = node.style.attributes; - if (attrs.containsKey(Attribute.blockQuote.key)) { - return defaultStyles!.quote!.verticalSpacing; - } else if (attrs.containsKey(Attribute.codeBlock.key)) { - return defaultStyles!.code!.verticalSpacing; - } else if (attrs.containsKey(Attribute.indent.key)) { - return defaultStyles!.indent!.verticalSpacing; - } else if (attrs.containsKey(Attribute.list.key)) { - return defaultStyles!.lists!.verticalSpacing; - } else if (attrs.containsKey(Attribute.align.key)) { - return defaultStyles!.align!.verticalSpacing; - } - return const VerticalSpacing(0, 0); - } - - void _didChangeTextEditingValueListener() { - _didChangeTextEditingValue(controller.ignoreFocusOnTextChange); - } - - @override - void initState() { - super.initState(); - - _clipboardStatus.addListener(_onChangedClipboardStatus); - - controller.addListener(_didChangeTextEditingValueListener); - - _scrollController = widget.scrollController; - _scrollController.addListener(_updateSelectionOverlayForScroll); - - _cursorCont = CursorCont( - show: ValueNotifier(widget.showCursor), - style: widget.cursorStyle, - tickerProvider: this, - ); - - // Floating cursor - _floatingCursorResetController = AnimationController(vsync: this); - _floatingCursorResetController.addListener(onFloatingCursorResetTick); - - if (isKeyboardOS()) { - _keyboardVisible = true; - } else if (!isWeb() && isFlutterTest()) { - // treat tests like a keyboard OS - _keyboardVisible = true; - } else { - // treat iOS Simulator like a keyboard OS - isIOSSimulator().then((isIosSimulator) { - if (isIosSimulator) { - _keyboardVisible = true; - } else { - _keyboardVisibilityController = KeyboardVisibilityController(); - _keyboardVisible = _keyboardVisibilityController!.isVisible; - _keyboardVisibilitySubscription = - _keyboardVisibilityController?.onChange.listen((visible) { - _keyboardVisible = visible; - if (visible) { - _onChangeTextEditingValue(!_hasFocus); - } - }); - - HardwareKeyboard.instance.addHandler(_hardwareKeyboardEvent); - } - }); - } - - // Focus - widget.focusNode.addListener(_handleFocusChanged); - } - - // KeyboardVisibilityController only checks for keyboards that - // adjust the screen size. Also watch for hardware keyboards - // that don't alter the screen (i.e. Chromebook, Android tablet - // and any hardware keyboards from an OS not listed in isKeyboardOS()) - bool _hardwareKeyboardEvent(KeyEvent _) { - if (!_keyboardVisible) { - // hardware keyboard key pressed. Set visibility to true - _keyboardVisible = true; - // update the editor - _onChangeTextEditingValue(!_hasFocus); - } - - // remove the key handler - it's no longer needed. If - // KeyboardVisibilityController clears visibility, it wil - // also enable it when appropriate. - HardwareKeyboard.instance.removeHandler(_hardwareKeyboardEvent); - - // we didn't handle the event, just needed to know a key was pressed - return false; - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final parentStyles = QuillStyles.getStyles(context, true); - final defaultStyles = DefaultStyles.getInstance(context); - _styles = (parentStyles != null) - ? defaultStyles.merge(parentStyles) - : defaultStyles; - - if (widget.customStyles != null) { - _styles = _styles!.merge(widget.customStyles!); - } - - _requestAutoFocusIfShould(); - } - - Future _requestAutoFocusIfShould() async { - final focusManager = FocusScope.of(context); - if (!_didAutoFocus && widget.autoFocus) { - await Future.delayed(Duration.zero); // To avoid exceptions - focusManager.autofocus(widget.focusNode); - _didAutoFocus = true; - } - } - - @override - void didUpdateWidget(QuillRawEditor oldWidget) { - super.didUpdateWidget(oldWidget); - - _cursorCont.show.value = widget.showCursor; - _cursorCont.style = widget.cursorStyle; - - if (controller != oldWidget.controller) { - oldWidget.controller.removeListener(_didChangeTextEditingValue); - controller.addListener(_didChangeTextEditingValue); - updateRemoteValueIfNeeded(); - } - - if (widget.scrollController != _scrollController) { - _scrollController.removeListener(_updateSelectionOverlayForScroll); - _scrollController = widget.scrollController; - _scrollController.addListener(_updateSelectionOverlayForScroll); - } - - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode.removeListener(_handleFocusChanged); - widget.focusNode.addListener(_handleFocusChanged); - updateKeepAlive(); - } - - if (controller.selection != oldWidget.controller.selection) { - _selectionOverlay?.update(textEditingValue); - } - - _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); - if (!shouldCreateInputConnection) { - closeConnectionIfNeeded(); - } else { - if (oldWidget.readOnly && _hasFocus) { - openConnectionIfNeeded(); - } - } - - // in case customStyles changed in new widget - if (widget.customStyles != null) { - _styles = _styles!.merge(widget.customStyles!); - } - } - - bool _shouldShowSelectionHandles() { - return widget.showSelectionHandles && !controller.selection.isCollapsed; - } - - @override - void dispose() { - closeConnectionIfNeeded(); - _keyboardVisibilitySubscription?.cancel(); - HardwareKeyboard.instance.removeHandler(_hardwareKeyboardEvent); - assert(!hasConnection); - _selectionOverlay?.dispose(); - _selectionOverlay = null; - controller.removeListener(_didChangeTextEditingValueListener); - widget.focusNode.removeListener(_handleFocusChanged); - _cursorCont.dispose(); - _clipboardStatus - ..removeListener(_onChangedClipboardStatus) - ..dispose(); - super.dispose(); - } - - void _updateSelectionOverlayForScroll() { - _selectionOverlay?.updateForScroll(); - } - - /// Marks the editor as dirty and trigger a rebuild. - /// - /// When the editor is dirty methods that depend on the editor - /// state being in sync with the controller know they may be - /// operating on stale data. - void _markNeedsBuild() { - if (_dirty) { - // No need to rebuilt if it already darty - return; - } - setState(() { - _dirty = true; - }); - } - - void _didChangeTextEditingValue([bool ignoreFocus = false]) { - if (isWeb()) { - _onChangeTextEditingValue(ignoreFocus); - if (!ignoreFocus) { - requestKeyboard(); - } - return; - } - - if (ignoreFocus || _keyboardVisible) { - _onChangeTextEditingValue(ignoreFocus); - } else { - requestKeyboard(); - if (mounted) { - // Use controller.value in build() - // Mark widget as dirty and trigger build and updateChildren - _markNeedsBuild(); - } - } - - _adjacentLineAction.stopCurrentVerticalRunIfSelectionChanges(); - } - - void _onChangeTextEditingValue([bool ignoreCaret = false]) { - updateRemoteValueIfNeeded(); - if (ignoreCaret) { - return; - } - _showCaretOnScreen(); - _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); - if (hasConnection) { - // To keep the cursor from blinking while typing, we want to restart the - // cursor timer every time a new character is typed. - _cursorCont - ..stopCursorTimer(resetCharTicks: false) - ..startCursorTimer(); - } - - // Refresh selection overlay after the build step had a chance to - // update and register all children of RenderEditor. Otherwise this will - // fail in situations where a new line of text is entered, which adds - // a new RenderEditableBox child. If we try to update selection overlay - // immediately it'll not be able to find the new child since it hasn't been - // built yet. - SchedulerBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - _updateOrDisposeSelectionOverlayIfNeeded(); - }); - if (mounted) { - // Use controller.value in build() - // Mark widget as dirty and trigger build and updateChildren - _markNeedsBuild(); - } - } - - void _updateOrDisposeSelectionOverlayIfNeeded() { - if (_selectionOverlay != null) { - if (!_hasFocus || textEditingValue.selection.isCollapsed) { - _selectionOverlay!.dispose(); - _selectionOverlay = null; - } else { - _selectionOverlay!.update(textEditingValue); - } - } else if (_hasFocus) { - _selectionOverlay = EditorTextSelectionOverlay( - value: textEditingValue, - context: context, - debugRequiredFor: widget, - 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(); - } - } - - void _handleFocusChanged() { - if (dirty) { - SchedulerBinding.instance - .addPostFrameCallback((_) => _handleFocusChanged()); - return; - } - openOrCloseConnection(); - _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); - _updateOrDisposeSelectionOverlayIfNeeded(); - if (_hasFocus) { - WidgetsBinding.instance.addObserver(this); - _showCaretOnScreen(); - } else { - WidgetsBinding.instance.removeObserver(this); - } - updateKeepAlive(); - } - - void _onChangedClipboardStatus() { - if (!mounted) return; - // Inform the widget that the value of clipboardStatus has changed. - // Trigger build and updateChildren - _markNeedsBuild(); - } - - Future _linkActionPicker(Node linkNode) async { - final link = linkNode.style.attributes[Attribute.link.key]!.value!; - return widget.linkActionPickerDelegate(context, link, linkNode); - } - - bool _showCaretOnScreenScheduled = false; - - // This is a workaround for checkbox tapping issue - // https://github.com/singerdmx/flutter-quill/issues/619 - // We cannot treat {"list": "checked"} and {"list": "unchecked"} as - // block of the same style - // This causes controller.selection to go to offset 0 - bool _disableScrollControllerAnimateOnce = false; - - void _showCaretOnScreen() { - if (!widget.showCursor || _showCaretOnScreenScheduled) { - return; - } - - _showCaretOnScreenScheduled = true; - SchedulerBinding.instance.addPostFrameCallback((_) { - if (widget.scrollable || _scrollController.hasClients) { - _showCaretOnScreenScheduled = false; - - if (!mounted) { - return; - } - - final viewport = RenderAbstractViewport.of(renderEditor); - final editorOffset = - renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport); - final offsetInViewport = _scrollController.offset + editorOffset.dy; - - final offset = renderEditor.getOffsetToRevealCursor( - _scrollController.position.viewportDimension, - _scrollController.offset, - offsetInViewport, - ); - - if (offset != null) { - if (_disableScrollControllerAnimateOnce) { - _disableScrollControllerAnimateOnce = false; - return; - } - _scrollController.animateTo( - math.min(offset, _scrollController.position.maxScrollExtent), - duration: const Duration(milliseconds: 100), - curve: Curves.fastOutSlowIn, - ); - } - } - }); - } - - /// The renderer for this widget's editor descendant. - /// - /// This property is typically used to notify the renderer of input gestures. - @override - RenderEditor get renderEditor => - _editorKey.currentContext!.findRenderObject() as RenderEditor; - - /// Express interest in interacting with the keyboard. - /// - /// If this control is already attached to the keyboard, this function will - /// request that the keyboard become visible. Otherwise, this function will - /// ask the focus system that it become focused. If successful in acquiring - /// focus, the control will then attach to the keyboard and request that the - /// keyboard become visible. - @override - void requestKeyboard() { - if (controller.skipRequestKeyboard) { - // and that just by one simple change - controller.skipRequestKeyboard = false; - return; - } - if (_hasFocus) { - final keyboardAlreadyShown = _keyboardVisible; - openConnectionIfNeeded(); - if (!keyboardAlreadyShown) { - /// delay 500 milliseconds for waiting keyboard show up - Future.delayed( - const Duration(milliseconds: 500), - _showCaretOnScreen, - ); - } else { - _showCaretOnScreen(); - } - } else { - widget.focusNode.requestFocus(); - } - } - - /// Shows the selection toolbar at the location of the current cursor. - /// - /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar - /// is already shown, or when no text selection currently exists. - @override - bool showToolbar() { - // Web is using native dom elements to enable clipboard functionality of the - // toolbar: copy, paste, select, cut. It might also provide additional - // functionality depending on the browser (such as translate). Due to this - // we should not show a Flutter toolbar for the editable text elements. - if (isWeb()) { - return false; - } - - // selectionOverlay is aggressively released when selection is collapsed - // to remove unnecessary handles. Since a toolbar is requested here, - // attempt to create the selectionOverlay if it's not already created. - if (_selectionOverlay == null) { - _updateOrDisposeSelectionOverlayIfNeeded(); - } - - if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) { - return false; - } - - _selectionOverlay!.update(textEditingValue); - _selectionOverlay!.showToolbar(); - return true; - } - - void _replaceText(ReplaceTextIntent intent) { - userUpdateTextEditingValue( - intent.currentTextEditingValue - .replaced(intent.replacementRange, intent.replacementText), - intent.cause, - ); - } - - /// 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 (cause == SelectionChangedCause.toolbar) { - 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), - ), - SelectionChangedCause.toolbar, - ); - } - } - - /// Cut current selection to [Clipboard]. - @override - void cutSelection(SelectionChangedCause cause) { - controller.copiedImageUrl = null; - _pastePlainText = controller.getPlainText(); - _pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed(); - - if (widget.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 (cause == SelectionChangedCause.toolbar) { - bringIntoView(textEditingValue.selection.extent); - hideToolbar(); - } - } - - /// Paste text from [Clipboard]. - @override - Future pasteText(SelectionChangedCause cause) async { - if (widget.readOnly) { - return; - } - - if (controller.copiedImageUrl != null) { - final index = textEditingValue.selection.baseOffset; - final length = textEditingValue.selection.extentOffset - index; - final copied = controller.copiedImageUrl!; - controller.replaceText( - index, - length, - BlockEmbed.image(copied.url), - null, - ); - if (copied.styleString.isNotEmpty) { - controller.formatText( - getEmbedNode(controller, index + 1).offset, - 1, - StyleAttribute(copied.styleString), - ); - } - controller.copiedImageUrl = null; - await Clipboard.setData( - const ClipboardData(text: ''), - ); - return; - } - - final selection = textEditingValue.selection; - if (!selection.isValid) { - return; - } - // Snapshot the input before using `await`. - // See https://github.com/flutter/flutter/issues/11427 - final text = await Clipboard.getData(Clipboard.kTextPlain); - if (text != null) { - _replaceText( - ReplaceTextIntent( - textEditingValue, - text.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.onImagePaste; - if (onImagePaste != null) { - final image = await Pasteboard.image; - - if (image == null) { - return; - } - - final imageUrl = await onImagePaste(image); - if (imageUrl == null) { - return; - } - - controller.replaceText( - textEditingValue.selection.end, - 0, - BlockEmbed.image(imageUrl), - null, - ); - } - } - - /// Select the entire text value. - @override - void selectAll(SelectionChangedCause cause) { - userUpdateTextEditingValue( - textEditingValue.copyWith( - selection: TextSelection( - baseOffset: 0, extentOffset: textEditingValue.text.length), - ), - cause, - ); - - if (cause == SelectionChangedCause.toolbar) { - bringIntoView(textEditingValue.selection.extent); - } - } - - @override - bool get wantKeepAlive => widget.focusNode.hasFocus; - - @override - AnimationController get floatingCursorResetController => - _floatingCursorResetController; - - late AnimationController _floatingCursorResetController; - - // --------------------------- Text Editing Actions -------------------------- - - _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { - final _TextBoundary atomicTextBoundary = - _CharacterBoundary(textEditingValue); - return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward); - } - - _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { - final _TextBoundary atomicTextBoundary; - final _TextBoundary boundary; - - // final TextEditingValue textEditingValue = - // _textEditingValueforTextLayoutMetrics; - atomicTextBoundary = _CharacterBoundary(textEditingValue); - // This isn't enough. Newline characters. - boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), - _WordBoundary(renderEditor, textEditingValue)); - - final mixedBoundary = intent.forward - ? _MixedBoundary(atomicTextBoundary, boundary) - : _MixedBoundary(boundary, atomicTextBoundary); - // Use a _MixedBoundary to make sure we don't leave invalid codepoints in - // the field after deletion. - return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); - } - - _TextBoundary _linebreak(DirectionalTextEditingIntent intent) { - final _TextBoundary atomicTextBoundary; - final _TextBoundary boundary; - - // final TextEditingValue textEditingValue = - // _textEditingValueforTextLayoutMetrics; - atomicTextBoundary = _CharacterBoundary(textEditingValue); - boundary = _LineBreak(renderEditor, textEditingValue); - - // The _MixedBoundary is to make sure we don't leave invalid code units in - // the field after deletion. - // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, - // since the document boundary is unique and the linebreak boundary is - // already caret-location based. - return intent.forward - ? _MixedBoundary( - _CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) - : _MixedBoundary( - boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false)); - } - - _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => - _DocumentBoundary(textEditingValue); - - Action _makeOverridable(Action defaultAction) { - return Action.overridable( - context: context, defaultAction: defaultAction); - } - - late final Action _replaceTextAction = - CallbackAction(onInvoke: _replaceText); - - void _updateSelection(UpdateSelectionIntent intent) { - userUpdateTextEditingValue( - intent.currentTextEditingValue.copyWith(selection: intent.newSelection), - intent.cause, - ); - } - - late final Action _updateSelectionAction = - CallbackAction(onInvoke: _updateSelection); - - late final _UpdateTextSelectionToAdjacentLineAction< - ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = - _UpdateTextSelectionToAdjacentLineAction< - ExtendSelectionVerticallyToAdjacentLineIntent>(this); - - late final _ToggleTextStyleAction _formatSelectionAction = - _ToggleTextStyleAction(this); - - late final _IndentSelectionAction _indentSelectionAction = - _IndentSelectionAction(this); - - late final _OpenSearchAction _openSearchAction = _OpenSearchAction(this); - late final _ApplyHeaderAction _applyHeaderAction = _ApplyHeaderAction(this); - late final _ApplyCheckListAction _applyCheckListAction = - _ApplyCheckListAction(this); - - late final Map> _actions = >{ - DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), - ReplaceTextIntent: _replaceTextAction, - UpdateSelectionIntent: _updateSelectionAction, - DirectionalFocusIntent: DirectionalFocusAction.forTextField(), - - // Delete - DeleteCharacterIntent: _makeOverridable( - _DeleteTextAction(this, _characterBoundary)), - DeleteToNextWordBoundaryIntent: _makeOverridable( - _DeleteTextAction( - this, _nextWordBoundary)), - DeleteToLineBreakIntent: _makeOverridable( - _DeleteTextAction(this, _linebreak)), - - // Extend/Move Selection - ExtendSelectionByCharacterIntent: _makeOverridable( - _UpdateTextSelectionAction( - this, - false, - _characterBoundary, - )), - ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( - _UpdateTextSelectionAction( - this, true, _nextWordBoundary)), - ExtendSelectionToLineBreakIntent: _makeOverridable( - _UpdateTextSelectionAction( - this, true, _linebreak)), - ExtendSelectionVerticallyToAdjacentLineIntent: - _makeOverridable(_adjacentLineAction), - ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( - _UpdateTextSelectionAction( - this, true, _documentBoundary)), - ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( - _ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), - - // Copy Paste - SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), - CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), - PasteTextIntent: _makeOverridable(CallbackAction( - onInvoke: (intent) => pasteText(intent.cause))), - - HideSelectionToolbarIntent: - _makeOverridable(_HideSelectionToolbarAction(this)), - UndoTextIntent: _makeOverridable(_UndoKeyboardAction(this)), - RedoTextIntent: _makeOverridable(_RedoKeyboardAction(this)), - - OpenSearchIntent: _openSearchAction, - - // Selection Formatting - ToggleTextStyleIntent: _formatSelectionAction, - IndentSelectionIntent: _indentSelectionAction, - ApplyHeaderIntent: _applyHeaderAction, - ApplyCheckListIntent: _applyCheckListAction, - ApplyLinkIntent: ApplyLinkAction(this) - }; - - @override - void insertTextPlaceholder(Size size) { - // this is needed for Scribble (Stylus input) in Apple platforms - // and this package does not implement this feature - } - - @override - void removeTextPlaceholder() { - // this is needed for Scribble (Stylus input) in Apple platforms - // and this package does not implement this feature - } - - @override - void didChangeInputControl( - TextInputControl? oldControl, - TextInputControl? newControl, - ) { - // TODO: implement didChangeInputControl - } - - @override - void performSelector(String selectorName) { - final intent = intentForMacOSSelector(selectorName); - - if (intent != null) { - final primaryContext = primaryFocus?.context; - if (primaryContext != null) { - Actions.invoke(primaryContext, intent); - } - } - } - - @override - // TODO: implement liveTextInputEnabled - bool get liveTextInputEnabled => false; -} - -class _Editor extends MultiChildRenderObjectWidget { - const _Editor({ - required Key super.key, - required super.children, - required this.document, - required this.textDirection, - required this.hasFocus, - required this.scrollable, - required this.selection, - required this.startHandleLayerLink, - required this.endHandleLayerLink, - required this.onSelectionChanged, - required this.onSelectionCompleted, - required this.scrollBottomInset, - required this.cursorController, - required this.floatingCursorDisabled, - this.padding = EdgeInsets.zero, - this.maxContentWidth, - this.offset, - }); - - final ViewportOffset? offset; - final Document document; - final TextDirection textDirection; - final bool hasFocus; - final bool scrollable; - final TextSelection selection; - final LayerLink startHandleLayerLink; - final LayerLink endHandleLayerLink; - final TextSelectionChangedHandler onSelectionChanged; - final TextSelectionCompletedHandler onSelectionCompleted; - final double scrollBottomInset; - final EdgeInsetsGeometry padding; - final double? maxContentWidth; - final CursorCont cursorController; - final bool floatingCursorDisabled; - - @override - RenderEditor createRenderObject(BuildContext context) { - return RenderEditor( - offset: offset, - document: document, - textDirection: textDirection, - hasFocus: hasFocus, - scrollable: scrollable, - selection: selection, - startHandleLayerLink: startHandleLayerLink, - endHandleLayerLink: endHandleLayerLink, - onSelectionChanged: onSelectionChanged, - onSelectionCompleted: onSelectionCompleted, - cursorController: cursorController, - padding: padding, - maxContentWidth: maxContentWidth, - scrollBottomInset: scrollBottomInset, - floatingCursorDisabled: floatingCursorDisabled, - ); - } - - @override - void updateRenderObject( - BuildContext context, - covariant RenderEditor renderObject, - ) { - renderObject - ..offset = offset - ..document = document - ..setContainer(document.root) - ..textDirection = textDirection - ..setHasFocus(hasFocus) - ..setSelection(selection) - ..setStartHandleLayerLink(startHandleLayerLink) - ..setEndHandleLayerLink(endHandleLayerLink) - ..onSelectionChanged = onSelectionChanged - ..setScrollBottomInset(scrollBottomInset) - ..setPadding(padding) - ..maxContentWidth = maxContentWidth; - } -} - -/// An interface for retrieving the logical text boundary -/// (left-closed-right-open) -/// at a given location in a document. -/// -/// Depending on the implementation of the [_TextBoundary], the input -/// [TextPosition] can either point to a code unit, or a position between 2 code -/// units (which can be visually represented by the caret if the selection were -/// to collapse to that position). -/// -/// For example, [_LineBreak] interprets the input [TextPosition] as a caret -/// location, since in Flutter the caret is generally painted between the -/// character the [TextPosition] points to and its previous character, and -/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most -/// other text boundaries however, interpret the input [TextPosition] as the -/// location of a code unit in the document, since it's easier to reason about -/// the text boundary given a code unit in the text. -/// -/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based", -/// use the [_CollapsedSelectionBoundary] combinator. -abstract class _TextBoundary { - const _TextBoundary(); - - TextEditingValue get textEditingValue; - - /// Returns the leading text boundary at the given location, inclusive. - TextPosition getLeadingTextBoundaryAt(TextPosition position); - - /// Returns the trailing text boundary at the given location, exclusive. - TextPosition getTrailingTextBoundaryAt(TextPosition position); - - TextRange getTextBoundaryAt(TextPosition position) { - return TextRange( - start: getLeadingTextBoundaryAt(position).offset, - end: getTrailingTextBoundaryAt(position).offset, - ); - } -} - -// ----------------------------- Text Boundaries ----------------------------- - -// The word modifier generally removes the word boundaries around white spaces -// (and newlines), IOW white spaces and some other punctuations are considered -// a part of the next word in the search direction. -class _WhitespaceBoundary extends _TextBoundary { - const _WhitespaceBoundary(this.textEditingValue); - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - for (var index = position.offset; index >= 0; index -= 1) { - if (!TextLayoutMetrics.isWhitespace( - textEditingValue.text.codeUnitAt(index))) { - return TextPosition(offset: index); - } - } - return const TextPosition(offset: 0); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - for (var index = position.offset; - index < textEditingValue.text.length; - index += 1) { - if (!TextLayoutMetrics.isWhitespace( - textEditingValue.text.codeUnitAt(index))) { - return TextPosition(offset: index + 1); - } - } - return TextPosition(offset: textEditingValue.text.length); - } -} - -// Most apps delete the entire grapheme when the backspace key is pressed. -// Also always put the new caret location to character boundaries to avoid -// sending malformed UTF-16 code units to the paragraph builder. -class _CharacterBoundary extends _TextBoundary { - const _CharacterBoundary(this.textEditingValue); - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - final int endOffset = - math.min(position.offset + 1, textEditingValue.text.length); - return TextPosition( - offset: - CharacterRange.at(textEditingValue.text, position.offset, endOffset) - .stringBeforeLength, - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - final int endOffset = - math.min(position.offset + 1, textEditingValue.text.length); - final range = - CharacterRange.at(textEditingValue.text, position.offset, endOffset); - return TextPosition( - offset: textEditingValue.text.length - range.stringAfterLength, - ); - } - - @override - TextRange getTextBoundaryAt(TextPosition position) { - final int endOffset = - math.min(position.offset + 1, textEditingValue.text.length); - final range = - CharacterRange.at(textEditingValue.text, position.offset, endOffset); - return TextRange( - start: range.stringBeforeLength, - end: textEditingValue.text.length - range.stringAfterLength, - ); - } -} - -// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. -class _WordBoundary extends _TextBoundary { - const _WordBoundary(this.textLayout, this.textEditingValue); - - final TextLayoutMetrics textLayout; - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getWordBoundary(position).start, - // Word boundary seems to always report downstream on many platforms. - affinity: - TextAffinity.downstream, // ignore: avoid_redundant_argument_values - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getWordBoundary(position).end, - // Word boundary seems to always report downstream on many platforms. - affinity: - TextAffinity.downstream, // ignore: avoid_redundant_argument_values - ); - } -} - -// The linebreaks of the current text layout. The input [TextPosition]s are -// interpreted as caret locations because [TextPainter.getLineAtOffset] is -// text-affinity-aware. -class _LineBreak extends _TextBoundary { - const _LineBreak(this.textLayout, this.textEditingValue); - - final TextLayoutMetrics textLayout; - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getLineAtOffset(position).start, - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getLineAtOffset(position).end, - affinity: TextAffinity.upstream, - ); - } -} - -// The document boundary is unique and is a constant function of the input -// position. -class _DocumentBoundary extends _TextBoundary { - const _DocumentBoundary(this.textEditingValue); - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) => - const TextPosition(offset: 0); - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textEditingValue.text.length, - affinity: TextAffinity.upstream, - ); - } -} - -// ------------------------ Text Boundary Combinators ------------------------ - -// Expands the innerTextBoundary with outerTextBoundary. -class _ExpandedTextBoundary extends _TextBoundary { - _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary); - - final _TextBoundary innerTextBoundary; - final _TextBoundary outerTextBoundary; - - @override - TextEditingValue get textEditingValue { - assert(innerTextBoundary.textEditingValue == - outerTextBoundary.textEditingValue); - return innerTextBoundary.textEditingValue; - } - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return outerTextBoundary.getLeadingTextBoundaryAt( - innerTextBoundary.getLeadingTextBoundaryAt(position), - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return outerTextBoundary.getTrailingTextBoundaryAt( - innerTextBoundary.getTrailingTextBoundaryAt(position), - ); - } -} - -// Force the innerTextBoundary to interpret the input [TextPosition]s as caret -// locations instead of code unit positions. -// -// The innerTextBoundary must be a [_TextBoundary] that interprets the input -// [TextPosition]s as code unit positions. -class _CollapsedSelectionBoundary extends _TextBoundary { - _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); - - final _TextBoundary innerTextBoundary; - final bool isForward; - - @override - TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return isForward - ? innerTextBoundary.getLeadingTextBoundaryAt(position) - : position.offset <= 0 - ? const TextPosition(offset: 0) - : innerTextBoundary.getLeadingTextBoundaryAt( - TextPosition(offset: position.offset - 1)); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return isForward - ? innerTextBoundary.getTrailingTextBoundaryAt(position) - : position.offset <= 0 - ? const TextPosition(offset: 0) - : innerTextBoundary.getTrailingTextBoundaryAt( - TextPosition(offset: position.offset - 1)); - } -} - -// A _TextBoundary that creates a [TextRange] where its start is from the -// specified leading text boundary and its end is from the specified trailing -// text boundary. -class _MixedBoundary extends _TextBoundary { - _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); - - final _TextBoundary leadingTextBoundary; - final _TextBoundary trailingTextBoundary; - - @override - TextEditingValue get textEditingValue { - assert(leadingTextBoundary.textEditingValue == - trailingTextBoundary.textEditingValue); - return leadingTextBoundary.textEditingValue; - } - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) => - leadingTextBoundary.getLeadingTextBoundaryAt(position); - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) => - trailingTextBoundary.getTrailingTextBoundaryAt(position); -} - -// ------------------------------- Text Actions ------------------------------- -class _DeleteTextAction - extends ContextAction { - _DeleteTextAction(this.state, this.getTextBoundariesForIntent); - - final QuillRawEditorState state; - final _TextBoundary Function(T intent) getTextBoundariesForIntent; - - TextRange _expandNonCollapsedRange(TextEditingValue value) { - final TextRange selection = value.selection; - assert(selection.isValid); - assert(!selection.isCollapsed); - final _TextBoundary atomicBoundary = _CharacterBoundary(value); - - return TextRange( - start: atomicBoundary - .getLeadingTextBoundaryAt(TextPosition(offset: selection.start)) - .offset, - end: atomicBoundary - .getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)) - .offset, - ); - } - - @override - Object? invoke(T intent, [BuildContext? context]) { - final selection = state.textEditingValue.selection; - assert(selection.isValid); - - if (!selection.isCollapsed) { - return Actions.invoke( - context!, - ReplaceTextIntent( - state.textEditingValue, - '', - _expandNonCollapsedRange(state.textEditingValue), - SelectionChangedCause.keyboard), - ); - } - - final textBoundary = getTextBoundariesForIntent(intent); - if (!textBoundary.textEditingValue.selection.isValid) { - return null; - } - if (!textBoundary.textEditingValue.selection.isCollapsed) { - return Actions.invoke( - context!, - ReplaceTextIntent( - state.textEditingValue, - '', - _expandNonCollapsedRange(textBoundary.textEditingValue), - SelectionChangedCause.keyboard), - ); - } - - return Actions.invoke( - context!, - ReplaceTextIntent( - textBoundary.textEditingValue, - '', - textBoundary - .getTextBoundaryAt(textBoundary.textEditingValue.selection.base), - SelectionChangedCause.keyboard, - ), - ); - } - - @override - bool get isActionEnabled => - !state.widget.readOnly && state.textEditingValue.selection.isValid; -} - -class _UpdateTextSelectionAction - extends ContextAction { - _UpdateTextSelectionAction(this.state, this.ignoreNonCollapsedSelection, - this.getTextBoundariesForIntent); - - final QuillRawEditorState state; - final bool ignoreNonCollapsedSelection; - final _TextBoundary Function(T intent) getTextBoundariesForIntent; - - @override - Object? invoke(T intent, [BuildContext? context]) { - final selection = state.textEditingValue.selection; - assert(selection.isValid); - - final collapseSelection = - intent.collapseSelection || !state.widget.selectionEnabled; - // Collapse to the logical start/end. - TextSelection collapse(TextSelection selection) { - assert(selection.isValid); - assert(!selection.isCollapsed); - return selection.copyWith( - baseOffset: intent.forward ? selection.end : selection.start, - extentOffset: intent.forward ? selection.end : selection.start, - ); - } - - if (!selection.isCollapsed && - !ignoreNonCollapsedSelection && - collapseSelection) { - return Actions.invoke( - context!, - UpdateSelectionIntent(state.textEditingValue, collapse(selection), - SelectionChangedCause.keyboard), - ); - } - - final textBoundary = getTextBoundariesForIntent(intent); - final textBoundarySelection = textBoundary.textEditingValue.selection; - if (!textBoundarySelection.isValid) { - return null; - } - if (!textBoundarySelection.isCollapsed && - !ignoreNonCollapsedSelection && - collapseSelection) { - return Actions.invoke( - context!, - UpdateSelectionIntent(state.textEditingValue, - collapse(textBoundarySelection), SelectionChangedCause.keyboard), - ); - } - - final extent = textBoundarySelection.extent; - final newExtent = intent.forward - ? textBoundary.getTrailingTextBoundaryAt(extent) - : textBoundary.getLeadingTextBoundaryAt(extent); - - final newSelection = collapseSelection - ? TextSelection.fromPosition(newExtent) - : textBoundarySelection.extendTo(newExtent); - - // If collapseAtReversal is true and would have an effect, collapse it. - if (!selection.isCollapsed && - intent.collapseAtReversal && - (selection.baseOffset < selection.extentOffset != - newSelection.baseOffset < newSelection.extentOffset)) { - return Actions.invoke( - context!, - UpdateSelectionIntent( - state.textEditingValue, - TextSelection.fromPosition(selection.base), - SelectionChangedCause.keyboard, - ), - ); - } - - return Actions.invoke( - context!, - UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, - SelectionChangedCause.keyboard), - ); - } - - @override - bool get isActionEnabled => state.textEditingValue.selection.isValid; -} - -class _ExtendSelectionOrCaretPositionAction extends ContextAction< - ExtendSelectionToNextWordBoundaryOrCaretLocationIntent> { - _ExtendSelectionOrCaretPositionAction( - this.state, this.getTextBoundariesForIntent); - - final QuillRawEditorState state; - final _TextBoundary Function( - ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) - getTextBoundariesForIntent; - - @override - Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, - [BuildContext? context]) { - final selection = state.textEditingValue.selection; - assert(selection.isValid); - - final textBoundary = getTextBoundariesForIntent(intent); - final textBoundarySelection = textBoundary.textEditingValue.selection; - if (!textBoundarySelection.isValid) { - return null; - } - - final extent = textBoundarySelection.extent; - final newExtent = intent.forward - ? textBoundary.getTrailingTextBoundaryAt(extent) - : textBoundary.getLeadingTextBoundaryAt(extent); - - final newSelection = (newExtent.offset - textBoundarySelection.baseOffset) * - (textBoundarySelection.extentOffset - - textBoundarySelection.baseOffset) < - 0 - ? textBoundarySelection.copyWith( - extentOffset: textBoundarySelection.baseOffset, - affinity: textBoundarySelection.extentOffset > - textBoundarySelection.baseOffset - ? TextAffinity.downstream - : TextAffinity.upstream, - ) - : textBoundarySelection.extendTo(newExtent); - - return Actions.invoke( - context!, - UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, - SelectionChangedCause.keyboard), - ); - } - - @override - bool get isActionEnabled => - state.widget.selectionEnabled && state.textEditingValue.selection.isValid; -} - -class _UpdateTextSelectionToAdjacentLineAction< - T extends DirectionalCaretMovementIntent> extends ContextAction { - _UpdateTextSelectionToAdjacentLineAction(this.state); - - final QuillRawEditorState state; - - QuillVerticalCaretMovementRun? _verticalMovementRun; - TextSelection? _runSelection; - - void stopCurrentVerticalRunIfSelectionChanges() { - final runSelection = _runSelection; - if (runSelection == null) { - assert(_verticalMovementRun == null); - return; - } - _runSelection = state.textEditingValue.selection; - final currentSelection = state.controller.selection; - final continueCurrentRun = currentSelection.isValid && - currentSelection.isCollapsed && - currentSelection.baseOffset == runSelection.baseOffset && - currentSelection.extentOffset == runSelection.extentOffset; - if (!continueCurrentRun) { - _verticalMovementRun = null; - _runSelection = null; - } - } - - @override - void invoke(T intent, [BuildContext? context]) { - assert(state.textEditingValue.selection.isValid); - - final collapseSelection = - intent.collapseSelection || !state.widget.selectionEnabled; - final value = state.textEditingValue; - if (!value.selection.isValid) { - return; - } - - final currentRun = _verticalMovementRun ?? - state.renderEditor - .startVerticalCaretMovement(state.renderEditor.selection.extent); - - final shouldMove = - intent.forward ? currentRun.moveNext() : currentRun.movePrevious(); - final newExtent = shouldMove - ? currentRun.current - : (intent.forward - ? TextPosition(offset: state.textEditingValue.text.length) - : const TextPosition(offset: 0)); - final newSelection = collapseSelection - ? TextSelection.fromPosition(newExtent) - : value.selection.extendTo(newExtent); - - Actions.invoke( - context!, - UpdateSelectionIntent( - value, newSelection, SelectionChangedCause.keyboard), - ); - if (state.textEditingValue.selection == newSelection) { - _verticalMovementRun = currentRun; - _runSelection = newSelection; - } - } - - @override - bool get isActionEnabled => state.textEditingValue.selection.isValid; -} - -class _SelectAllAction extends ContextAction { - _SelectAllAction(this.state); - - final QuillRawEditorState state; - - @override - Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { - return Actions.invoke( - context!, - UpdateSelectionIntent( - state.textEditingValue, - TextSelection( - baseOffset: 0, extentOffset: state.textEditingValue.text.length), - intent.cause, - ), - ); - } - - @override - bool get isActionEnabled => state.widget.selectionEnabled; -} - -class _CopySelectionAction extends ContextAction { - _CopySelectionAction(this.state); - - final QuillRawEditorState state; - - @override - void invoke(CopySelectionTextIntent intent, [BuildContext? context]) { - if (intent.collapseSelection) { - state.cutSelection(intent.cause); - } else { - state.copySelection(intent.cause); - } - } - - @override - bool get isActionEnabled => - state.textEditingValue.selection.isValid && - !state.textEditingValue.selection.isCollapsed; -} - -//Intent class for "escape" key to dismiss selection toolbar in Windows platform -class HideSelectionToolbarIntent extends Intent { - const HideSelectionToolbarIntent(); -} - -class _HideSelectionToolbarAction - extends ContextAction { - _HideSelectionToolbarAction(this.state); - - final QuillRawEditorState state; - - @override - void invoke(HideSelectionToolbarIntent intent, [BuildContext? context]) { - state.hideToolbar(); - } - - @override - bool get isActionEnabled => state.textEditingValue.selection.isValid; -} - -class _UndoKeyboardAction extends ContextAction { - _UndoKeyboardAction(this.state); - - final QuillRawEditorState state; - - @override - void invoke(UndoTextIntent intent, [BuildContext? context]) { - if (state.controller.hasUndo) { - state.controller.undo(); - } - } - - @override - bool get isActionEnabled => true; -} - -class _RedoKeyboardAction extends ContextAction { - _RedoKeyboardAction(this.state); - - final QuillRawEditorState state; - - @override - void invoke(RedoTextIntent intent, [BuildContext? context]) { - if (state.controller.hasRedo) { - state.controller.redo(); - } - } - - @override - bool get isActionEnabled => true; -} - -class ToggleTextStyleIntent extends Intent { - const ToggleTextStyleIntent(this.attribute); - - final Attribute attribute; -} - -// Toggles a text style (underline, bold, italic, strikethrough) on, or off. -class _ToggleTextStyleAction extends Action { - _ToggleTextStyleAction(this.state); - - final QuillRawEditorState state; - - bool _isStyleActive(Attribute styleAttr, Map attrs) { - if (styleAttr.key == Attribute.list.key) { - final attribute = attrs[styleAttr.key]; - if (attribute == null) { - return false; - } - return attribute.value == styleAttr.value; - } - return attrs.containsKey(styleAttr.key); - } - - @override - void invoke(ToggleTextStyleIntent intent, [BuildContext? context]) { - final isActive = _isStyleActive( - intent.attribute, state.controller.getSelectionStyle().attributes); - state.controller.formatSelection( - isActive ? Attribute.clone(intent.attribute, null) : intent.attribute); - } - - @override - bool get isActionEnabled => true; -} - -class IndentSelectionIntent extends Intent { - const IndentSelectionIntent(this.isIncrease); - - final bool isIncrease; -} - -// Toggles a text style (underline, bold, italic, strikethrough) on, or off. -class _IndentSelectionAction extends Action { - _IndentSelectionAction(this.state); - - final QuillRawEditorState state; - - @override - void invoke(IndentSelectionIntent intent, [BuildContext? context]) { - state.controller.indentSelection(intent.isIncrease); - } - - @override - bool get isActionEnabled => true; -} - -class OpenSearchIntent extends Intent { - const OpenSearchIntent(); -} - -// Toggles a text style (underline, bold, italic, strikethrough) on, or off. -class _OpenSearchAction extends ContextAction { - _OpenSearchAction(this.state); - - final QuillRawEditorState state; - - @override - Future invoke(OpenSearchIntent intent, [BuildContext? context]) async { - if (context == null) { - throw ArgumentError( - 'The context should not be null to use invoke() method', - ); - } - await showDialog( - context: context, - builder: (_) => QuillToolbarSearchDialog( - controller: state.controller, - text: '', - ), - ); - } - - @override - bool get isActionEnabled => true; -} - -class ApplyHeaderIntent extends Intent { - const ApplyHeaderIntent(this.header); - - final Attribute header; -} - -// Toggles a text style (underline, bold, italic, strikethrough) on, or off. -class _ApplyHeaderAction extends Action { - _ApplyHeaderAction(this.state); - - final QuillRawEditorState state; - - Attribute _getHeaderValue() { - return state.controller - .getSelectionStyle() - .attributes[Attribute.header.key] ?? - Attribute.header; - } - - @override - void invoke(ApplyHeaderIntent intent, [BuildContext? context]) { - final attribute = - _getHeaderValue() == intent.header ? Attribute.header : intent.header; - state.controller.formatSelection(attribute); - } - - @override - bool get isActionEnabled => true; -} - -class ApplyCheckListIntent extends Intent { - const ApplyCheckListIntent(); -} - -// Toggles a text style (underline, bold, italic, strikethrough) on, or off. -class _ApplyCheckListAction extends Action { - _ApplyCheckListAction(this.state); - - final QuillRawEditorState state; - - bool _getIsToggled() { - final attrs = state.controller.getSelectionStyle().attributes; - var attribute = state.controller.toolbarButtonToggler[Attribute.list.key]; - - if (attribute == null) { - attribute = attrs[Attribute.list.key]; - } else { - // checkbox tapping causes controller.selection to go to offset 0 - state.controller.toolbarButtonToggler.remove(Attribute.list.key); - } - - if (attribute == null) { - return false; - } - return attribute.value == Attribute.unchecked.value || - attribute.value == Attribute.checked.value; - } - - @override - void invoke(ApplyCheckListIntent intent, [BuildContext? context]) { - state.controller.formatSelection(_getIsToggled() - ? Attribute.clone(Attribute.unchecked, null) - : Attribute.unchecked); - } - - @override - bool get isActionEnabled => true; -} - -class ApplyLinkIntent extends Intent { - const ApplyLinkIntent(); -} - -class ApplyLinkAction extends Action { - ApplyLinkAction(this.state); - - final QuillRawEditorState state; - - @override - Object? invoke(ApplyLinkIntent intent) async { - final initialTextLink = QuillTextLink.prepare(state.controller); - - final textLink = await showDialog( - context: state.context, - builder: (context) { - return LinkStyleDialog( - text: initialTextLink.text, - link: initialTextLink.link, - dialogTheme: state.widget.dialogTheme, - ); - }, - ); - - if (textLink != null) { - textLink.submit(state.controller); - } - return null; - } -} - -class InsertEmbedIntent extends Intent { - const InsertEmbedIntent(this.type); - - final Attribute type; -} - /// Signature for a widget builder that builds a context menu for the given /// [QuillRawEditorState]. /// @@ -2783,8 +257,8 @@ typedef QuillEditorContextMenuBuilder = Widget Function( QuillRawEditorState rawEditorState, ); -class _GlyphHeights { - _GlyphHeights( +class QuillEditorGlyphHeights { + QuillEditorGlyphHeights( this.startGlyphHeight, this.endGlyphHeight, ); diff --git a/lib/src/widgets/raw_editor/raw_editor_actions.dart b/lib/src/widgets/raw_editor/raw_editor_actions.dart new file mode 100644 index 00000000..e0564d3f --- /dev/null +++ b/lib/src/widgets/raw_editor/raw_editor_actions.dart @@ -0,0 +1,578 @@ +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../editor/editor.dart'; +import '../toolbar/buttons/link_style2.dart'; +import '../toolbar/buttons/search/search_dialog.dart'; +import 'raw_editor_state.dart'; +import 'raw_editor_text_boundaries.dart'; + +// ------------------------------- Text Actions ------------------------------- +class QuillEditorDeleteTextAction + extends ContextAction { + QuillEditorDeleteTextAction(this.state, this.getTextBoundariesForIntent); + + final QuillRawEditorState state; + final QuillEditorTextBoundary Function(T intent) getTextBoundariesForIntent; + + TextRange _expandNonCollapsedRange(TextEditingValue value) { + final TextRange selection = value.selection; + assert(selection.isValid); + assert(!selection.isCollapsed); + final atomicBoundary = QuillEditorCharacterBoundary(value); + + return TextRange( + start: atomicBoundary + .getLeadingTextBoundaryAt(TextPosition(offset: selection.start)) + .offset, + end: atomicBoundary + .getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)) + .offset, + ); + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + final selection = state.textEditingValue.selection; + assert(selection.isValid); + + if (!selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent( + state.textEditingValue, + '', + _expandNonCollapsedRange(state.textEditingValue), + SelectionChangedCause.keyboard), + ); + } + + final textBoundary = getTextBoundariesForIntent(intent); + if (!textBoundary.textEditingValue.selection.isValid) { + return null; + } + if (!textBoundary.textEditingValue.selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent( + state.textEditingValue, + '', + _expandNonCollapsedRange(textBoundary.textEditingValue), + SelectionChangedCause.keyboard), + ); + } + + return Actions.invoke( + context!, + ReplaceTextIntent( + textBoundary.textEditingValue, + '', + textBoundary + .getTextBoundaryAt(textBoundary.textEditingValue.selection.base), + SelectionChangedCause.keyboard, + ), + ); + } + + @override + bool get isActionEnabled => + !state.widget.readOnly && state.textEditingValue.selection.isValid; +} + +class QuillEditorUpdateTextSelectionAction< + T extends DirectionalCaretMovementIntent> extends ContextAction { + QuillEditorUpdateTextSelectionAction(this.state, + this.ignoreNonCollapsedSelection, this.getTextBoundariesForIntent); + + final QuillRawEditorState state; + final bool ignoreNonCollapsedSelection; + final QuillEditorTextBoundary Function(T intent) getTextBoundariesForIntent; + + @override + Object? invoke(T intent, [BuildContext? context]) { + final selection = state.textEditingValue.selection; + assert(selection.isValid); + + final collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + // Collapse to the logical start/end. + TextSelection collapse(TextSelection selection) { + assert(selection.isValid); + assert(!selection.isCollapsed); + return selection.copyWith( + baseOffset: intent.forward ? selection.end : selection.start, + extentOffset: intent.forward ? selection.end : selection.start, + ); + } + + if (!selection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state.textEditingValue, + collapse(selection), + SelectionChangedCause.keyboard, + ), + ); + } + + final textBoundary = getTextBoundariesForIntent(intent); + final textBoundarySelection = textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + if (!textBoundarySelection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent(state.textEditingValue, + collapse(textBoundarySelection), SelectionChangedCause.keyboard), + ); + } + + final extent = textBoundarySelection.extent; + final newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : textBoundarySelection.extendTo(newExtent); + + // If collapseAtReversal is true and would have an effect, collapse it. + if (!selection.isCollapsed && + intent.collapseAtReversal && + (selection.baseOffset < selection.extentOffset != + newSelection.baseOffset < newSelection.extentOffset)) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state.textEditingValue, + TextSelection.fromPosition(selection.base), + SelectionChangedCause.keyboard, + ), + ); + } + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, + SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => state.textEditingValue.selection.isValid; +} + +class QuillEditorExtendSelectionOrCaretPositionAction extends ContextAction< + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent> { + QuillEditorExtendSelectionOrCaretPositionAction( + this.state, this.getTextBoundariesForIntent); + + final QuillRawEditorState state; + final QuillEditorTextBoundary Function( + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) + getTextBoundariesForIntent; + + @override + Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, + [BuildContext? context]) { + final selection = state.textEditingValue.selection; + assert(selection.isValid); + + final textBoundary = getTextBoundariesForIntent(intent); + final textBoundarySelection = textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + + final extent = textBoundarySelection.extent; + final newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final newSelection = (newExtent.offset - textBoundarySelection.baseOffset) * + (textBoundarySelection.extentOffset - + textBoundarySelection.baseOffset) < + 0 + ? textBoundarySelection.copyWith( + extentOffset: textBoundarySelection.baseOffset, + affinity: textBoundarySelection.extentOffset > + textBoundarySelection.baseOffset + ? TextAffinity.downstream + : TextAffinity.upstream, + ) + : textBoundarySelection.extendTo(newExtent); + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, + SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => + state.widget.selectionEnabled && state.textEditingValue.selection.isValid; +} + +class QuillEditorUpdateTextSelectionToAdjacentLineAction< + T extends DirectionalCaretMovementIntent> extends ContextAction { + QuillEditorUpdateTextSelectionToAdjacentLineAction(this.state); + + final QuillRawEditorState state; + + QuillVerticalCaretMovementRun? _verticalMovementRun; + TextSelection? _runSelection; + + void stopCurrentVerticalRunIfSelectionChanges() { + final runSelection = _runSelection; + if (runSelection == null) { + assert(_verticalMovementRun == null); + return; + } + _runSelection = state.textEditingValue.selection; + final currentSelection = state.controller.selection; + final continueCurrentRun = currentSelection.isValid && + currentSelection.isCollapsed && + currentSelection.baseOffset == runSelection.baseOffset && + currentSelection.extentOffset == runSelection.extentOffset; + if (!continueCurrentRun) { + _verticalMovementRun = null; + _runSelection = null; + } + } + + @override + void invoke(T intent, [BuildContext? context]) { + assert(state.textEditingValue.selection.isValid); + + final collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + final value = state.textEditingValue; + if (!value.selection.isValid) { + return; + } + + final currentRun = _verticalMovementRun ?? + state.renderEditor + .startVerticalCaretMovement(state.renderEditor.selection.extent); + + final shouldMove = + intent.forward ? currentRun.moveNext() : currentRun.movePrevious(); + final newExtent = shouldMove + ? currentRun.current + : (intent.forward + ? TextPosition(offset: state.textEditingValue.text.length) + : const TextPosition(offset: 0)); + final newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : value.selection.extendTo(newExtent); + + Actions.invoke( + context!, + UpdateSelectionIntent( + value, newSelection, SelectionChangedCause.keyboard), + ); + if (state.textEditingValue.selection == newSelection) { + _verticalMovementRun = currentRun; + _runSelection = newSelection; + } + } + + @override + bool get isActionEnabled => state.textEditingValue.selection.isValid; +} + +class QuillEditorSelectAllAction extends ContextAction { + QuillEditorSelectAllAction(this.state); + + final QuillRawEditorState state; + + @override + Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state.textEditingValue, + TextSelection( + baseOffset: 0, extentOffset: state.textEditingValue.text.length), + intent.cause, + ), + ); + } + + @override + bool get isActionEnabled => state.widget.selectionEnabled; +} + +class QuillEditorCopySelectionAction + extends ContextAction { + QuillEditorCopySelectionAction(this.state); + + final QuillRawEditorState state; + + @override + void invoke(CopySelectionTextIntent intent, [BuildContext? context]) { + if (intent.collapseSelection) { + state.cutSelection(intent.cause); + } else { + state.copySelection(intent.cause); + } + } + + @override + bool get isActionEnabled => + state.textEditingValue.selection.isValid && + !state.textEditingValue.selection.isCollapsed; +} + +//Intent class for "escape" key to dismiss selection toolbar in Windows platform +class HideSelectionToolbarIntent extends Intent { + const HideSelectionToolbarIntent(); +} + +class QuillEditorHideSelectionToolbarAction + extends ContextAction { + QuillEditorHideSelectionToolbarAction(this.state); + + final QuillRawEditorState state; + + @override + void invoke(HideSelectionToolbarIntent intent, [BuildContext? context]) { + state.hideToolbar(); + } + + @override + bool get isActionEnabled => state.textEditingValue.selection.isValid; +} + +class QuillEditorUndoKeyboardAction extends ContextAction { + QuillEditorUndoKeyboardAction(this.state); + + final QuillRawEditorState state; + + @override + void invoke(UndoTextIntent intent, [BuildContext? context]) { + if (state.controller.hasUndo) { + state.controller.undo(); + } + } + + @override + bool get isActionEnabled => true; +} + +class QuillEditorRedoKeyboardAction extends ContextAction { + QuillEditorRedoKeyboardAction(this.state); + + final QuillRawEditorState state; + + @override + void invoke(RedoTextIntent intent, [BuildContext? context]) { + if (state.controller.hasRedo) { + state.controller.redo(); + } + } + + @override + bool get isActionEnabled => true; +} + +class ToggleTextStyleIntent extends Intent { + const ToggleTextStyleIntent(this.attribute); + + final Attribute attribute; +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class QuillEditorToggleTextStyleAction extends Action { + QuillEditorToggleTextStyleAction(this.state); + + final QuillRawEditorState state; + + bool _isStyleActive(Attribute styleAttr, Map attrs) { + if (styleAttr.key == Attribute.list.key) { + final attribute = attrs[styleAttr.key]; + if (attribute == null) { + return false; + } + return attribute.value == styleAttr.value; + } + return attrs.containsKey(styleAttr.key); + } + + @override + void invoke(ToggleTextStyleIntent intent, [BuildContext? context]) { + final isActive = _isStyleActive( + intent.attribute, state.controller.getSelectionStyle().attributes); + state.controller.formatSelection( + isActive ? Attribute.clone(intent.attribute, null) : intent.attribute); + } + + @override + bool get isActionEnabled => true; +} + +class IndentSelectionIntent extends Intent { + const IndentSelectionIntent(this.isIncrease); + + final bool isIncrease; +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class QuillEditorIndentSelectionAction extends Action { + QuillEditorIndentSelectionAction(this.state); + + final QuillRawEditorState state; + + @override + void invoke(IndentSelectionIntent intent, [BuildContext? context]) { + state.controller.indentSelection(intent.isIncrease); + } + + @override + bool get isActionEnabled => true; +} + +class OpenSearchIntent extends Intent { + const OpenSearchIntent(); +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class QuillEditorOpenSearchAction extends ContextAction { + QuillEditorOpenSearchAction(this.state); + + final QuillRawEditorState state; + + @override + Future invoke(OpenSearchIntent intent, [BuildContext? context]) async { + if (context == null) { + throw ArgumentError( + 'The context should not be null to use invoke() method', + ); + } + await showDialog( + context: context, + builder: (_) => QuillToolbarSearchDialog( + controller: state.controller, + text: '', + ), + ); + } + + @override + bool get isActionEnabled => true; +} + +class QuillEditorApplyHeaderIntent extends Intent { + const QuillEditorApplyHeaderIntent(this.header); + + final Attribute header; +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class QuillEditorApplyHeaderAction + extends Action { + QuillEditorApplyHeaderAction(this.state); + + final QuillRawEditorState state; + + Attribute _getHeaderValue() { + return state.controller + .getSelectionStyle() + .attributes[Attribute.header.key] ?? + Attribute.header; + } + + @override + void invoke(QuillEditorApplyHeaderIntent intent, [BuildContext? context]) { + final attribute = + _getHeaderValue() == intent.header ? Attribute.header : intent.header; + state.controller.formatSelection(attribute); + } + + @override + bool get isActionEnabled => true; +} + +class QuillEditorApplyCheckListIntent extends Intent { + const QuillEditorApplyCheckListIntent(); +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class QuillEditorApplyCheckListAction + extends Action { + QuillEditorApplyCheckListAction(this.state); + + final QuillRawEditorState state; + + bool _getIsToggled() { + final attrs = state.controller.getSelectionStyle().attributes; + var attribute = state.controller.toolbarButtonToggler[Attribute.list.key]; + + if (attribute == null) { + attribute = attrs[Attribute.list.key]; + } else { + // checkbox tapping causes controller.selection to go to offset 0 + state.controller.toolbarButtonToggler.remove(Attribute.list.key); + } + + if (attribute == null) { + return false; + } + return attribute.value == Attribute.unchecked.value || + attribute.value == Attribute.checked.value; + } + + @override + void invoke(QuillEditorApplyCheckListIntent intent, [BuildContext? context]) { + state.controller.formatSelection(_getIsToggled() + ? Attribute.clone(Attribute.unchecked, null) + : Attribute.unchecked); + } + + @override + bool get isActionEnabled => true; +} + +class QuillEditorApplyLinkIntent extends Intent { + const QuillEditorApplyLinkIntent(); +} + +class QuillEditorApplyLinkAction extends Action { + QuillEditorApplyLinkAction(this.state); + + final QuillRawEditorState state; + + @override + Object? invoke(QuillEditorApplyLinkIntent intent) async { + final initialTextLink = QuillTextLink.prepare(state.controller); + + final textLink = await showDialog( + context: state.context, + builder: (context) { + return LinkStyleDialog( + text: initialTextLink.text, + link: initialTextLink.link, + dialogTheme: state.widget.dialogTheme, + ); + }, + ); + + if (textLink != null) { + textLink.submit(state.controller); + } + return null; + } +} + +class QuillEditorInsertEmbedIntent extends Intent { + const QuillEditorInsertEmbedIntent(this.type); + + final Attribute type; +} diff --git a/lib/src/widgets/raw_editor/raw_editor_state.dart b/lib/src/widgets/raw_editor/raw_editor_state.dart new file mode 100644 index 00000000..12ff1135 --- /dev/null +++ b/lib/src/widgets/raw_editor/raw_editor_state.dart @@ -0,0 +1,1617 @@ +import 'dart:async' show StreamSubscription; +import 'dart:convert' show jsonDecode; +import 'dart:math' as math; +import 'dart:ui' as ui hide TextStyle; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderAbstractViewport; +import 'package:flutter/scheduler.dart' show SchedulerBinding; +import 'package:flutter/services.dart' + show + LogicalKeyboardKey, + RawKeyDownEvent, + HardwareKeyboard, + Clipboard, + ClipboardData, + TextInputControl; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart' + show KeyboardVisibilityController; +import 'package:pasteboard/pasteboard.dart' show Pasteboard; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/document.dart'; +import '../../models/documents/nodes/block.dart'; +import '../../models/documents/nodes/embeddable.dart'; +import '../../models/documents/nodes/leaf.dart' as leaf; +import '../../models/documents/nodes/line.dart'; +import '../../models/documents/nodes/node.dart'; +import '../../models/structs/offset_value.dart'; +import '../../models/structs/vertical_spacing.dart'; +import '../../utils/cast.dart'; +import '../../utils/delta.dart'; +import '../../utils/embeds.dart'; +import '../../utils/extensions/build_context.dart'; +import '../../utils/platform.dart'; +import '../controller.dart'; +import '../cursor.dart'; +import '../default_styles.dart'; +import '../editor/editor.dart'; +import '../keyboard_listener.dart'; +import '../link.dart'; +import '../proxy.dart'; +import '../quill_single_child_scroll_view.dart'; +import '../text_block.dart'; +import '../text_line.dart'; +import '../text_selection.dart'; +import 'raw_editor.dart'; +import 'raw_editor_actions.dart'; +import 'raw_editor_state_selection_delegate_mixin.dart'; +import 'raw_editor_state_text_input_client_mixin.dart'; +import 'raw_editor_text_boundaries.dart'; +import 'raw_editor_widget.dart'; + +class QuillRawEditorState extends EditorState + with + AutomaticKeepAliveClientMixin, + WidgetsBindingObserver, + TickerProviderStateMixin, + RawEditorStateTextInputClientMixin, + RawEditorStateSelectionDelegateMixin { + final GlobalKey _editorKey = GlobalKey(); + + KeyboardVisibilityController? _keyboardVisibilityController; + StreamSubscription? _keyboardVisibilitySubscription; + bool _keyboardVisible = false; + + // Selection overlay + @override + EditorTextSelectionOverlay? get selectionOverlay => _selectionOverlay; + EditorTextSelectionOverlay? _selectionOverlay; + + @override + ScrollController get scrollController => _scrollController; + late ScrollController _scrollController; + + // Cursors + late CursorCont _cursorCont; + + QuillController get controller => widget.controller; + + // Focus + bool _didAutoFocus = false; + + bool get _hasFocus => widget.focusNode.hasFocus; + + // Theme + DefaultStyles? _styles; + + // for pasting style + @override + List get pasteStyleAndEmbed => _pasteStyleAndEmbed; + List _pasteStyleAndEmbed = []; + + @override + String get pastePlainText => _pastePlainText; + String _pastePlainText = ''; + + final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); + final LayerLink _toolbarLayerLink = LayerLink(); + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + + TextDirection get _textDirection => Directionality.of(context); + + @override + bool get dirty => _dirty; + bool _dirty = false; + + @override + void insertContent(KeyboardInsertedContent content) { + assert(widget.contentInsertionConfiguration?.allowedMimeTypes + .contains(content.mimeType) ?? + false); + widget.contentInsertionConfiguration?.onContentInserted.call(content); + } + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu for [QuillRawEditor]. + /// + /// Copied from [EditableTextState]. + List get contextMenuButtonItems { + return EditableText.getEditableButtonItems( + clipboardStatus: _clipboardStatus.value, + onLiveTextInput: null, + 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.startGlyphHeight, + endGlyphHeight: glyphHeights.endGlyphHeight, + selectionEndpoints: points, + ); + } + + /// Gets the line heights at the start and end of the selection for the given + /// [QuillRawEditorState]. + /// + /// Copied from [EditableTextState]. + QuillEditorGlyphHeights _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 QuillEditorGlyphHeights( + renderEditor.preferredLineHeight(selection.base), + renderEditor.preferredLineHeight(selection.base), + ); + } + + final startCharacterRect = + renderEditor.getLocalRectForCaret(selection.base); + final endCharacterRect = + renderEditor.getLocalRectForCaret(selection.extent); + return QuillEditorGlyphHeights( + startCharacterRect.height, + endCharacterRect.height, + ); + } + + void _defaultOnTapOutside(PointerDownEvent event) { + if (isWeb()) { + widget.focusNode.unfocus(); + } + + /// The focus dropping behavior is only present on desktop platforms + /// and mobile browsers. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + // On mobile platforms, we don't unfocus on touch events unless they're + // in the web browser, but we do unfocus for all other kinds of events. + switch (event.kind) { + case ui.PointerDeviceKind.touch: + break; + case ui.PointerDeviceKind.mouse: + case ui.PointerDeviceKind.stylus: + case ui.PointerDeviceKind.invertedStylus: + case ui.PointerDeviceKind.unknown: + widget.focusNode.unfocus(); + break; + case ui.PointerDeviceKind.trackpad: + throw UnimplementedError( + 'Unexpected pointer down event for trackpad.', + ); + } + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + widget.focusNode.unfocus(); + break; + default: + throw UnsupportedError( + 'The platform ${defaultTargetPlatform.name} is not supported in the' + ' _defaultOnTapOutside()', + ); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + super.build(context); + + var doc = controller.document; + if (doc.isEmpty() && widget.placeholder != null) { + final raw = widget.placeholder?.replaceAll(r'"', '\\"'); + doc = Document.fromJson( + jsonDecode( + '[{"attributes":{"placeholder":true},"insert":"$raw\\n"}]', + ), + ); + } + + Widget child = CompositedTransformTarget( + link: _toolbarLayerLink, + child: Semantics( + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: QuilRawEditorMultiChildRenderObjectWidget( + key: _editorKey, + document: doc, + selection: controller.selection, + hasFocus: _hasFocus, + scrollable: widget.scrollable, + cursorController: _cursorCont, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + onSelectionCompleted: _handleSelectionCompleted, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + floatingCursorDisabled: widget.floatingCursorDisabled, + children: _buildChildren(doc, context), + ), + ), + ), + ); + + if (widget.scrollable) { + /// Since [SingleChildScrollView] does not implement + /// `computeDistanceToActualBaseline` it prevents the editor from + /// providing its baseline metrics. To address this issue we wrap + /// the scroll view with [BaselineProxy] which mimics the editor's + /// baseline. + // This implies that the first line has no styles applied to it. + final baselinePadding = + EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.top); + child = BaselineProxy( + textStyle: _styles!.paragraph!.style, + padding: baselinePadding, + child: QuillSingleChildScrollView( + controller: _scrollController, + physics: widget.scrollPhysics, + viewportBuilder: (_, offset) => CompositedTransformTarget( + link: _toolbarLayerLink, + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: QuilRawEditorMultiChildRenderObjectWidget( + key: _editorKey, + offset: offset, + document: doc, + selection: controller.selection, + hasFocus: _hasFocus, + scrollable: widget.scrollable, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + onSelectionCompleted: _handleSelectionCompleted, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + cursorController: _cursorCont, + floatingCursorDisabled: widget.floatingCursorDisabled, + children: _buildChildren(doc, context), + ), + ), + ), + ), + ); + } + + final constraints = widget.expands + ? const BoxConstraints.expand() + : BoxConstraints( + minHeight: widget.minHeight ?? 0.0, + maxHeight: widget.maxHeight ?? double.infinity, + ); + + // Please notice that this change will make the check fixed + // so if we ovveride the platform in material app theme data + // it will not depend on it and doesn't change here but I don't think + // we need to + final isDesktopMacOS = isMacOS(); + + return TextFieldTapRegion( + enabled: widget.enableUnfocusOnTapOutside, + onTapOutside: (event) { + final onTapOutside = + context.requireQuillEditorConfigurations.onTapOutside; + if (onTapOutside != null) { + context.requireQuillEditorConfigurations.onTapOutside + ?.call(event, widget.focusNode); + return; + } + _defaultOnTapOutside(event); + }, + child: QuillStyles( + data: _styles!, + child: Shortcuts( + shortcuts: mergeMaps({ + // shortcuts added for Desktop platforms. + const SingleActivator( + LogicalKeyboardKey.escape, + ): const HideSelectionToolbarIntent(), + SingleActivator( + LogicalKeyboardKey.keyZ, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const UndoTextIntent(SelectionChangedCause.keyboard), + SingleActivator( + LogicalKeyboardKey.keyY, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const RedoTextIntent(SelectionChangedCause.keyboard), + + // Selection formatting. + SingleActivator( + LogicalKeyboardKey.keyB, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const ToggleTextStyleIntent(Attribute.bold), + SingleActivator( + LogicalKeyboardKey.keyU, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const ToggleTextStyleIntent(Attribute.underline), + SingleActivator( + LogicalKeyboardKey.keyI, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const ToggleTextStyleIntent(Attribute.italic), + SingleActivator( + LogicalKeyboardKey.keyS, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.strikeThrough), + SingleActivator( + LogicalKeyboardKey.backquote, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const ToggleTextStyleIntent(Attribute.inlineCode), + SingleActivator( + LogicalKeyboardKey.tilde, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.codeBlock), + SingleActivator( + LogicalKeyboardKey.keyB, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.blockQuote), + SingleActivator( + LogicalKeyboardKey.keyK, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const QuillEditorApplyLinkIntent(), + + // Lists + SingleActivator( + LogicalKeyboardKey.keyL, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.ul), + SingleActivator( + LogicalKeyboardKey.keyO, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.ol), + SingleActivator( + LogicalKeyboardKey.keyC, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const QuillEditorApplyCheckListIntent(), + + // Indents + SingleActivator( + LogicalKeyboardKey.keyM, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const IndentSelectionIntent(true), + SingleActivator( + LogicalKeyboardKey.keyM, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + shift: true, + ): const IndentSelectionIntent(false), + + // Headers + SingleActivator( + LogicalKeyboardKey.digit1, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const QuillEditorApplyHeaderIntent(Attribute.h1), + SingleActivator( + LogicalKeyboardKey.digit2, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const QuillEditorApplyHeaderIntent(Attribute.h2), + SingleActivator( + LogicalKeyboardKey.digit3, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const QuillEditorApplyHeaderIntent(Attribute.h3), + SingleActivator( + LogicalKeyboardKey.digit0, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const QuillEditorApplyHeaderIntent(Attribute.header), + + SingleActivator( + LogicalKeyboardKey.keyG, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const QuillEditorInsertEmbedIntent(Attribute.image), + + SingleActivator( + LogicalKeyboardKey.keyF, + control: !isDesktopMacOS, + meta: isDesktopMacOS, + ): const OpenSearchIntent(), + }, { + ...?widget.customShortcuts + }), + child: Actions( + actions: mergeMaps>(_actions, { + ...?widget.customActions, + }), + child: Focus( + focusNode: widget.focusNode, + onKey: _onKey, + child: QuillKeyboardListener( + child: Container( + constraints: constraints, + child: child, + ), + ), + ), + ), + ), + ), + ); + } + + KeyEventResult _onKey(node, RawKeyEvent event) { + // Don't handle key if there is a meta key pressed. + if (event.isAltPressed || event.isControlPressed || event.isMetaPressed) { + return KeyEventResult.ignored; + } + + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + // Handle indenting blocks when pressing the tab key. + if (event.logicalKey == LogicalKeyboardKey.tab) { + return _handleTabKey(event); + } + + // Don't handle key if there is an active selection. + if (controller.selection.baseOffset != controller.selection.extentOffset) { + return KeyEventResult.ignored; + } + + // Handle inserting lists when space is pressed following + // a list initiating phrase. + if (event.logicalKey == LogicalKeyboardKey.space) { + return _handleSpaceKey(event); + } + + return KeyEventResult.ignored; + } + + KeyEventResult _handleSpaceKey(RawKeyEvent event) { + final child = + controller.document.queryChild(controller.selection.baseOffset); + if (child.node == null) { + return KeyEventResult.ignored; + } + + final line = child.node as Line?; + if (line == null) { + return KeyEventResult.ignored; + } + + final text = castOrNull(line.first); + if (text == null) { + return KeyEventResult.ignored; + } + + const olKeyPhrase = '1.'; + const ulKeyPhrase = '-'; + + if (text.value == olKeyPhrase) { + _updateSelectionForKeyPhrase(olKeyPhrase, Attribute.ol); + } else if (text.value == ulKeyPhrase) { + _updateSelectionForKeyPhrase(ulKeyPhrase, Attribute.ul); + } else { + return KeyEventResult.ignored; + } + + return KeyEventResult.handled; + } + + KeyEventResult _handleTabKey(RawKeyEvent event) { + final child = + controller.document.queryChild(controller.selection.baseOffset); + + KeyEventResult insertTabCharacter() { + if (widget.readOnly) { + return KeyEventResult.ignored; + } + controller.replaceText(controller.selection.baseOffset, 0, '\t', null); + _moveCursor(1); + return KeyEventResult.handled; + } + + if (controller.selection.baseOffset != controller.selection.extentOffset) { + if (child.node == null || child.node!.parent == null) { + return KeyEventResult.handled; + } + final parentBlock = child.node!.parent!; + if (parentBlock.style.containsKey(Attribute.ol.key) || + parentBlock.style.containsKey(Attribute.ul.key) || + parentBlock.style.containsKey(Attribute.checked.key)) { + controller.indentSelection(!event.isShiftPressed); + } + return KeyEventResult.handled; + } + + if (child.node == null) { + return insertTabCharacter(); + } + + final node = child.node!; + + final parent = node.parent; + if (parent == null || parent is! Block) { + return insertTabCharacter(); + } + + if (node is! Line || (node.isNotEmpty && node.first is! leaf.QuillText)) { + return insertTabCharacter(); + } + + final parentBlock = parent; + if (parentBlock.style.containsKey(Attribute.ol.key) || + parentBlock.style.containsKey(Attribute.ul.key) || + parentBlock.style.containsKey(Attribute.checked.key)) { + if (node.isNotEmpty && + (node.first as leaf.QuillText).value.isNotEmpty && + controller.selection.base.offset > node.documentOffset) { + return insertTabCharacter(); + } + controller.indentSelection(!event.isShiftPressed); + return KeyEventResult.handled; + } + + if (node.isNotEmpty && (node.first as leaf.QuillText).value.isNotEmpty) { + return insertTabCharacter(); + } + + return insertTabCharacter(); + } + + void _moveCursor(int chars) { + final selection = controller.selection; + controller.updateSelection( + controller.selection.copyWith( + baseOffset: selection.baseOffset + chars, + extentOffset: selection.baseOffset + chars), + ChangeSource.local); + } + + void _updateSelectionForKeyPhrase(String phrase, Attribute attribute) { + controller.replaceText(controller.selection.baseOffset - phrase.length, + phrase.length, '\n', null); + _moveCursor(-phrase.length); + controller + ..formatSelection(attribute) + // Remove the added newline. + ..replaceText(controller.selection.baseOffset + 1, 1, '', null); + } + + void _handleSelectionChanged( + TextSelection selection, + SelectionChangedCause cause, + ) { + final oldSelection = controller.selection; + controller.updateSelection(selection, ChangeSource.local); + + _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); + + if (!_keyboardVisible) { + // This will show the keyboard for all selection changes on the + // editor, not just changes triggered by user gestures. + requestKeyboard(); + } + + if (cause == SelectionChangedCause.drag) { + // When user updates the selection while dragging make sure to + // bring the updated position (base or extent) into view. + if (oldSelection.baseOffset != selection.baseOffset) { + bringIntoView(selection.base); + } else if (oldSelection.extentOffset != selection.extentOffset) { + bringIntoView(selection.extent); + } + } + } + + void _handleSelectionCompleted() { + controller.onSelectionCompleted?.call(); + } + + /// Updates the checkbox positioned at [offset] in document + /// by changing its attribute according to [value]. + void _handleCheckboxTap(int offset, bool value) { + final requestKeyboardFocusOnCheckListChanged = context + .requireQuillEditorConfigurations + .requestKeyboardFocusOnCheckListChanged; + if (!widget.readOnly) { + _disableScrollControllerAnimateOnce = true; + final currentSelection = controller.selection.copyWith(); + final attribute = value ? Attribute.checked : Attribute.unchecked; + + _markNeedsBuild(); + controller + ..ignoreFocusOnTextChange = true + ..skipRequestKeyboard = !requestKeyboardFocusOnCheckListChanged + ..formatText(offset, 0, attribute) + + // Checkbox tapping causes controller.selection to go to offset 0 + // Stop toggling those two toolbar buttons + ..toolbarButtonToggler = { + Attribute.list.key: attribute, + Attribute.header.key: Attribute.header + }; + + // Go back from offset 0 to current selection + SchedulerBinding.instance.addPostFrameCallback((_) { + controller + ..ignoreFocusOnTextChange = false + ..skipRequestKeyboard = !requestKeyboardFocusOnCheckListChanged + ..updateSelection(currentSelection, ChangeSource.local); + }); + } + } + + List _buildChildren(Document doc, BuildContext context) { + final result = []; + final indentLevelCounts = {}; + // this need for several ordered list in document + // we need to reset indents Map, if list finished + // List finished when there is node without Attribute.ol in styles + // So in this case we set clearIndents=true and send it + // to the next EditableTextBlock + var prevNodeOl = false; + var clearIndents = false; + + for (final node in doc.root.children) { + final attrs = node.style.attributes; + + if (prevNodeOl && attrs[Attribute.list.key] != Attribute.ol) { + clearIndents = true; + } + + prevNodeOl = attrs[Attribute.list.key] == Attribute.ol; + + if (node is Line) { + final editableTextLine = _getEditableTextLineFromNode(node, context); + result.add(Directionality( + textDirection: getDirectionOfNode(node), child: editableTextLine)); + } else if (node is Block) { + final editableTextBlock = EditableTextBlock( + block: node, + controller: controller, + textDirection: getDirectionOfNode(node), + scrollBottomInset: widget.scrollBottomInset, + verticalSpacing: _getVerticalSpacingForBlock(node, _styles), + textSelection: controller.selection, + color: widget.selectionColor, + styles: _styles, + enableInteractiveSelection: widget.enableInteractiveSelection, + hasFocus: _hasFocus, + contentPadding: attrs.containsKey(Attribute.codeBlock.key) + ? const EdgeInsets.all(16) + : null, + embedBuilder: widget.embedBuilder, + linkActionPicker: _linkActionPicker, + onLaunchUrl: widget.onLaunchUrl, + cursorCont: _cursorCont, + indentLevelCounts: indentLevelCounts, + clearIndents: clearIndents, + onCheckboxTap: _handleCheckboxTap, + readOnly: widget.readOnly, + customStyleBuilder: widget.customStyleBuilder, + customLinkPrefixes: widget.customLinkPrefixes, + ); + result.add( + Directionality( + textDirection: getDirectionOfNode(node), + child: editableTextBlock, + ), + ); + + clearIndents = false; + } else { + _dirty = false; + throw StateError('Unreachable.'); + } + } + _dirty = false; + return result; + } + + EditableTextLine _getEditableTextLineFromNode( + Line node, BuildContext context) { + final textLine = TextLine( + line: node, + textDirection: _textDirection, + embedBuilder: widget.embedBuilder, + customStyleBuilder: widget.customStyleBuilder, + customRecognizerBuilder: widget.customRecognizerBuilder, + styles: _styles!, + readOnly: widget.readOnly, + controller: controller, + linkActionPicker: _linkActionPicker, + onLaunchUrl: widget.onLaunchUrl, + customLinkPrefixes: widget.customLinkPrefixes, + ); + final editableTextLine = EditableTextLine( + node, + null, + textLine, + 0, + _getVerticalSpacingForLine(node, _styles), + _textDirection, + controller.selection, + widget.selectionColor, + widget.enableInteractiveSelection, + _hasFocus, + MediaQuery.devicePixelRatioOf(context), + _cursorCont); + return editableTextLine; + } + + VerticalSpacing _getVerticalSpacingForLine( + Line line, + DefaultStyles? defaultStyles, + ) { + final attrs = line.style.attributes; + if (attrs.containsKey(Attribute.header.key)) { + int level; + if (attrs[Attribute.header.key]!.value is double) { + level = attrs[Attribute.header.key]!.value.toInt(); + } else { + level = attrs[Attribute.header.key]!.value; + } + switch (level) { + case 1: + return defaultStyles!.h1!.verticalSpacing; + case 2: + return defaultStyles!.h2!.verticalSpacing; + case 3: + return defaultStyles!.h3!.verticalSpacing; + default: + throw ArgumentError('Invalid level $level'); + } + } + + return defaultStyles!.paragraph!.verticalSpacing; + } + + VerticalSpacing _getVerticalSpacingForBlock( + Block node, DefaultStyles? defaultStyles) { + final attrs = node.style.attributes; + if (attrs.containsKey(Attribute.blockQuote.key)) { + return defaultStyles!.quote!.verticalSpacing; + } else if (attrs.containsKey(Attribute.codeBlock.key)) { + return defaultStyles!.code!.verticalSpacing; + } else if (attrs.containsKey(Attribute.indent.key)) { + return defaultStyles!.indent!.verticalSpacing; + } else if (attrs.containsKey(Attribute.list.key)) { + return defaultStyles!.lists!.verticalSpacing; + } else if (attrs.containsKey(Attribute.align.key)) { + return defaultStyles!.align!.verticalSpacing; + } + return const VerticalSpacing(0, 0); + } + + void _didChangeTextEditingValueListener() { + _didChangeTextEditingValue(controller.ignoreFocusOnTextChange); + } + + @override + void initState() { + super.initState(); + + _clipboardStatus.addListener(_onChangedClipboardStatus); + + controller.addListener(_didChangeTextEditingValueListener); + + _scrollController = widget.scrollController; + _scrollController.addListener(_updateSelectionOverlayForScroll); + + _cursorCont = CursorCont( + show: ValueNotifier(widget.showCursor), + style: widget.cursorStyle, + tickerProvider: this, + ); + + // Floating cursor + _floatingCursorResetController = AnimationController(vsync: this); + _floatingCursorResetController.addListener(onFloatingCursorResetTick); + + if (isKeyboardOS()) { + _keyboardVisible = true; + } else if (!isWeb() && isFlutterTest()) { + // treat tests like a keyboard OS + _keyboardVisible = true; + } else { + // treat iOS Simulator like a keyboard OS + isIOSSimulator().then((isIosSimulator) { + if (isIosSimulator) { + _keyboardVisible = true; + } else { + _keyboardVisibilityController = KeyboardVisibilityController(); + _keyboardVisible = _keyboardVisibilityController!.isVisible; + _keyboardVisibilitySubscription = + _keyboardVisibilityController?.onChange.listen((visible) { + _keyboardVisible = visible; + if (visible) { + _onChangeTextEditingValue(!_hasFocus); + } + }); + + HardwareKeyboard.instance.addHandler(_hardwareKeyboardEvent); + } + }); + } + + // Focus + widget.focusNode.addListener(_handleFocusChanged); + } + + // KeyboardVisibilityController only checks for keyboards that + // adjust the screen size. Also watch for hardware keyboards + // that don't alter the screen (i.e. Chromebook, Android tablet + // and any hardware keyboards from an OS not listed in isKeyboardOS()) + bool _hardwareKeyboardEvent(KeyEvent _) { + if (!_keyboardVisible) { + // hardware keyboard key pressed. Set visibility to true + _keyboardVisible = true; + // update the editor + _onChangeTextEditingValue(!_hasFocus); + } + + // remove the key handler - it's no longer needed. If + // KeyboardVisibilityController clears visibility, it wil + // also enable it when appropriate. + HardwareKeyboard.instance.removeHandler(_hardwareKeyboardEvent); + + // we didn't handle the event, just needed to know a key was pressed + return false; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parentStyles = QuillStyles.getStyles(context, true); + final defaultStyles = DefaultStyles.getInstance(context); + _styles = (parentStyles != null) + ? defaultStyles.merge(parentStyles) + : defaultStyles; + + if (widget.customStyles != null) { + _styles = _styles!.merge(widget.customStyles!); + } + + _requestAutoFocusIfShould(); + } + + Future _requestAutoFocusIfShould() async { + final focusManager = FocusScope.of(context); + if (!_didAutoFocus && widget.autoFocus) { + await Future.delayed(Duration.zero); // To avoid exceptions + focusManager.autofocus(widget.focusNode); + _didAutoFocus = true; + } + } + + @override + void didUpdateWidget(QuillRawEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + _cursorCont.show.value = widget.showCursor; + _cursorCont.style = widget.cursorStyle; + + if (controller != oldWidget.controller) { + oldWidget.controller.removeListener(_didChangeTextEditingValue); + controller.addListener(_didChangeTextEditingValue); + updateRemoteValueIfNeeded(); + } + + if (widget.scrollController != _scrollController) { + _scrollController.removeListener(_updateSelectionOverlayForScroll); + _scrollController = widget.scrollController; + _scrollController.addListener(_updateSelectionOverlayForScroll); + } + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + widget.focusNode.addListener(_handleFocusChanged); + updateKeepAlive(); + } + + if (controller.selection != oldWidget.controller.selection) { + _selectionOverlay?.update(textEditingValue); + } + + _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); + if (!shouldCreateInputConnection) { + closeConnectionIfNeeded(); + } else { + if (oldWidget.readOnly && _hasFocus) { + openConnectionIfNeeded(); + } + } + + // in case customStyles changed in new widget + if (widget.customStyles != null) { + _styles = _styles!.merge(widget.customStyles!); + } + } + + bool _shouldShowSelectionHandles() { + return widget.showSelectionHandles && !controller.selection.isCollapsed; + } + + @override + void dispose() { + closeConnectionIfNeeded(); + _keyboardVisibilitySubscription?.cancel(); + HardwareKeyboard.instance.removeHandler(_hardwareKeyboardEvent); + assert(!hasConnection); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + controller.removeListener(_didChangeTextEditingValueListener); + widget.focusNode.removeListener(_handleFocusChanged); + _cursorCont.dispose(); + _clipboardStatus + ..removeListener(_onChangedClipboardStatus) + ..dispose(); + super.dispose(); + } + + void _updateSelectionOverlayForScroll() { + _selectionOverlay?.updateForScroll(); + } + + /// Marks the editor as dirty and trigger a rebuild. + /// + /// When the editor is dirty methods that depend on the editor + /// state being in sync with the controller know they may be + /// operating on stale data. + void _markNeedsBuild() { + if (_dirty) { + // No need to rebuilt if it already darty + return; + } + setState(() { + _dirty = true; + }); + } + + void _didChangeTextEditingValue([bool ignoreFocus = false]) { + if (isWeb()) { + _onChangeTextEditingValue(ignoreFocus); + if (!ignoreFocus) { + requestKeyboard(); + } + return; + } + + if (ignoreFocus || _keyboardVisible) { + _onChangeTextEditingValue(ignoreFocus); + } else { + requestKeyboard(); + if (mounted) { + // Use controller.value in build() + // Mark widget as dirty and trigger build and updateChildren + _markNeedsBuild(); + } + } + + _adjacentLineAction.stopCurrentVerticalRunIfSelectionChanges(); + } + + void _onChangeTextEditingValue([bool ignoreCaret = false]) { + updateRemoteValueIfNeeded(); + if (ignoreCaret) { + return; + } + _showCaretOnScreen(); + _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); + if (hasConnection) { + // To keep the cursor from blinking while typing, we want to restart the + // cursor timer every time a new character is typed. + _cursorCont + ..stopCursorTimer(resetCharTicks: false) + ..startCursorTimer(); + } + + // Refresh selection overlay after the build step had a chance to + // update and register all children of RenderEditor. Otherwise this will + // fail in situations where a new line of text is entered, which adds + // a new RenderEditableBox child. If we try to update selection overlay + // immediately it'll not be able to find the new child since it hasn't been + // built yet. + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _updateOrDisposeSelectionOverlayIfNeeded(); + }); + if (mounted) { + // Use controller.value in build() + // Mark widget as dirty and trigger build and updateChildren + _markNeedsBuild(); + } + } + + void _updateOrDisposeSelectionOverlayIfNeeded() { + if (_selectionOverlay != null) { + if (!_hasFocus || textEditingValue.selection.isCollapsed) { + _selectionOverlay!.dispose(); + _selectionOverlay = null; + } else { + _selectionOverlay!.update(textEditingValue); + } + } else if (_hasFocus) { + _selectionOverlay = EditorTextSelectionOverlay( + value: textEditingValue, + context: context, + debugRequiredFor: widget, + 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(); + } + } + + void _handleFocusChanged() { + if (dirty) { + SchedulerBinding.instance + .addPostFrameCallback((_) => _handleFocusChanged()); + return; + } + openOrCloseConnection(); + _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); + _updateOrDisposeSelectionOverlayIfNeeded(); + if (_hasFocus) { + WidgetsBinding.instance.addObserver(this); + _showCaretOnScreen(); + } else { + WidgetsBinding.instance.removeObserver(this); + } + updateKeepAlive(); + } + + void _onChangedClipboardStatus() { + if (!mounted) return; + // Inform the widget that the value of clipboardStatus has changed. + // Trigger build and updateChildren + _markNeedsBuild(); + } + + Future _linkActionPicker(Node linkNode) async { + final link = linkNode.style.attributes[Attribute.link.key]!.value!; + return widget.linkActionPickerDelegate(context, link, linkNode); + } + + bool _showCaretOnScreenScheduled = false; + + // This is a workaround for checkbox tapping issue + // https://github.com/singerdmx/flutter-quill/issues/619 + // We cannot treat {"list": "checked"} and {"list": "unchecked"} as + // block of the same style + // This causes controller.selection to go to offset 0 + bool _disableScrollControllerAnimateOnce = false; + + void _showCaretOnScreen() { + if (!widget.showCursor || _showCaretOnScreenScheduled) { + return; + } + + _showCaretOnScreenScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (widget.scrollable || _scrollController.hasClients) { + _showCaretOnScreenScheduled = false; + + if (!mounted) { + return; + } + + final viewport = RenderAbstractViewport.of(renderEditor); + final editorOffset = + renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport); + final offsetInViewport = _scrollController.offset + editorOffset.dy; + + final offset = renderEditor.getOffsetToRevealCursor( + _scrollController.position.viewportDimension, + _scrollController.offset, + offsetInViewport, + ); + + if (offset != null) { + if (_disableScrollControllerAnimateOnce) { + _disableScrollControllerAnimateOnce = false; + return; + } + _scrollController.animateTo( + math.min(offset, _scrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 100), + curve: Curves.fastOutSlowIn, + ); + } + } + }); + } + + /// The renderer for this widget's editor descendant. + /// + /// This property is typically used to notify the renderer of input gestures. + @override + RenderEditor get renderEditor => + _editorKey.currentContext!.findRenderObject() as RenderEditor; + + /// Express interest in interacting with the keyboard. + /// + /// If this control is already attached to the keyboard, this function will + /// request that the keyboard become visible. Otherwise, this function will + /// ask the focus system that it become focused. If successful in acquiring + /// focus, the control will then attach to the keyboard and request that the + /// keyboard become visible. + @override + void requestKeyboard() { + if (controller.skipRequestKeyboard) { + // and that just by one simple change + controller.skipRequestKeyboard = false; + return; + } + if (_hasFocus) { + final keyboardAlreadyShown = _keyboardVisible; + openConnectionIfNeeded(); + if (!keyboardAlreadyShown) { + /// delay 500 milliseconds for waiting keyboard show up + Future.delayed( + const Duration(milliseconds: 500), + _showCaretOnScreen, + ); + } else { + _showCaretOnScreen(); + } + } else { + widget.focusNode.requestFocus(); + } + } + + /// Shows the selection toolbar at the location of the current cursor. + /// + /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar + /// is already shown, or when no text selection currently exists. + @override + bool showToolbar() { + // Web is using native dom elements to enable clipboard functionality of the + // toolbar: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this + // we should not show a Flutter toolbar for the editable text elements. + if (isWeb()) { + return false; + } + + // selectionOverlay is aggressively released when selection is collapsed + // to remove unnecessary handles. Since a toolbar is requested here, + // attempt to create the selectionOverlay if it's not already created. + if (_selectionOverlay == null) { + _updateOrDisposeSelectionOverlayIfNeeded(); + } + + if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) { + return false; + } + + _selectionOverlay!.update(textEditingValue); + _selectionOverlay!.showToolbar(); + return true; + } + + void _replaceText(ReplaceTextIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue + .replaced(intent.replacementRange, intent.replacementText), + intent.cause, + ); + } + + /// 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 (cause == SelectionChangedCause.toolbar) { + 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), + ), + SelectionChangedCause.toolbar, + ); + } + } + + /// Cut current selection to [Clipboard]. + @override + void cutSelection(SelectionChangedCause cause) { + controller.copiedImageUrl = null; + _pastePlainText = controller.getPlainText(); + _pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed(); + + if (widget.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 (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(); + } + } + + /// Paste text from [Clipboard]. + @override + Future pasteText(SelectionChangedCause cause) async { + if (widget.readOnly) { + return; + } + + if (controller.copiedImageUrl != null) { + final index = textEditingValue.selection.baseOffset; + final length = textEditingValue.selection.extentOffset - index; + final copied = controller.copiedImageUrl!; + controller.replaceText( + index, + length, + BlockEmbed.image(copied.url), + null, + ); + if (copied.styleString.isNotEmpty) { + controller.formatText( + getEmbedNode(controller, index + 1).offset, + 1, + StyleAttribute(copied.styleString), + ); + } + controller.copiedImageUrl = null; + await Clipboard.setData( + const ClipboardData(text: ''), + ); + return; + } + + final selection = textEditingValue.selection; + if (!selection.isValid) { + return; + } + // Snapshot the input before using `await`. + // See https://github.com/flutter/flutter/issues/11427 + final text = await Clipboard.getData(Clipboard.kTextPlain); + if (text != null) { + _replaceText( + ReplaceTextIntent( + textEditingValue, + text.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.onImagePaste; + if (onImagePaste != null) { + final image = await Pasteboard.image; + + if (image == null) { + return; + } + + final imageUrl = await onImagePaste(image); + if (imageUrl == null) { + return; + } + + controller.replaceText( + textEditingValue.selection.end, + 0, + BlockEmbed.image(imageUrl), + null, + ); + } + } + + /// Select the entire text value. + @override + void selectAll(SelectionChangedCause cause) { + userUpdateTextEditingValue( + textEditingValue.copyWith( + selection: TextSelection( + baseOffset: 0, extentOffset: textEditingValue.text.length), + ), + cause, + ); + + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + } + } + + @override + bool get wantKeepAlive => widget.focusNode.hasFocus; + + @override + AnimationController get floatingCursorResetController => + _floatingCursorResetController; + + late AnimationController _floatingCursorResetController; + + // --------------------------- Text Editing Actions -------------------------- + + QuillEditorTextBoundary _characterBoundary( + DirectionalTextEditingIntent intent) { + final atomicTextBoundary = QuillEditorCharacterBoundary(textEditingValue); + return QuillEditorCollapsedSelectionBoundary( + atomicTextBoundary, intent.forward); + } + + QuillEditorTextBoundary _nextWordBoundary( + DirectionalTextEditingIntent intent) { + final QuillEditorTextBoundary atomicTextBoundary; + final QuillEditorTextBoundary boundary; + + // final TextEditingValue textEditingValue = + // _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = QuillEditorCharacterBoundary(textEditingValue); + // This isn't enough. Newline characters. + boundary = QuillEditorExpandedTextBoundary( + QuillEditorWhitespaceBoundary(textEditingValue), + QuillEditorWordBoundary(renderEditor, textEditingValue)); + + final mixedBoundary = intent.forward + ? QuillEditorMixedBoundary(atomicTextBoundary, boundary) + : QuillEditorMixedBoundary(boundary, atomicTextBoundary); + // Use a _MixedBoundary to make sure we don't leave invalid codepoints in + // the field after deletion. + return QuillEditorCollapsedSelectionBoundary(mixedBoundary, intent.forward); + } + + QuillEditorTextBoundary _linebreak(DirectionalTextEditingIntent intent) { + final QuillEditorTextBoundary atomicTextBoundary; + final QuillEditorTextBoundary boundary; + + // final TextEditingValue textEditingValue = + // _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = QuillEditorCharacterBoundary(textEditingValue); + boundary = QuillEditorLineBreak(renderEditor, textEditingValue); + + // The _MixedBoundary is to make sure we don't leave invalid code units in + // the field after deletion. + // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, + // since the document boundary is unique and the linebreak boundary is + // already caret-location based. + return intent.forward + ? QuillEditorMixedBoundary( + QuillEditorCollapsedSelectionBoundary(atomicTextBoundary, true), + boundary) + : QuillEditorMixedBoundary( + boundary, + QuillEditorCollapsedSelectionBoundary(atomicTextBoundary, false), + ); + } + + QuillEditorTextBoundary _documentBoundary( + DirectionalTextEditingIntent intent) => + QuillEditorDocumentBoundary(textEditingValue); + + Action _makeOverridable(Action defaultAction) { + return Action.overridable( + context: context, defaultAction: defaultAction); + } + + late final Action _replaceTextAction = + CallbackAction(onInvoke: _replaceText); + + void _updateSelection(UpdateSelectionIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.cause, + ); + } + + late final Action _updateSelectionAction = + CallbackAction(onInvoke: _updateSelection); + + late final QuillEditorUpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = + QuillEditorUpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent>(this); + + late final QuillEditorToggleTextStyleAction _formatSelectionAction = + QuillEditorToggleTextStyleAction(this); + + late final QuillEditorIndentSelectionAction _indentSelectionAction = + QuillEditorIndentSelectionAction(this); + + late final QuillEditorOpenSearchAction _openSearchAction = + QuillEditorOpenSearchAction(this); + late final QuillEditorApplyHeaderAction _applyHeaderAction = + QuillEditorApplyHeaderAction(this); + late final QuillEditorApplyCheckListAction _applyCheckListAction = + QuillEditorApplyCheckListAction(this); + + late final Map> _actions = >{ + DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), + ReplaceTextIntent: _replaceTextAction, + UpdateSelectionIntent: _updateSelectionAction, + DirectionalFocusIntent: DirectionalFocusAction.forTextField(), + + // Delete + DeleteCharacterIntent: _makeOverridable( + QuillEditorDeleteTextAction( + this, _characterBoundary)), + DeleteToNextWordBoundaryIntent: _makeOverridable( + QuillEditorDeleteTextAction( + this, _nextWordBoundary)), + DeleteToLineBreakIntent: _makeOverridable( + QuillEditorDeleteTextAction(this, _linebreak)), + + // Extend/Move Selection + ExtendSelectionByCharacterIntent: _makeOverridable( + QuillEditorUpdateTextSelectionAction( + this, + false, + _characterBoundary, + )), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + QuillEditorUpdateTextSelectionAction< + ExtendSelectionToNextWordBoundaryIntent>( + this, true, _nextWordBoundary)), + ExtendSelectionToLineBreakIntent: _makeOverridable( + QuillEditorUpdateTextSelectionAction( + this, true, _linebreak)), + ExtendSelectionVerticallyToAdjacentLineIntent: + _makeOverridable(_adjacentLineAction), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + QuillEditorUpdateTextSelectionAction< + ExtendSelectionToDocumentBoundaryIntent>( + this, true, _documentBoundary)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + QuillEditorExtendSelectionOrCaretPositionAction( + this, _nextWordBoundary)), + + // Copy Paste + SelectAllTextIntent: _makeOverridable(QuillEditorSelectAllAction(this)), + CopySelectionTextIntent: + _makeOverridable(QuillEditorCopySelectionAction(this)), + PasteTextIntent: _makeOverridable(CallbackAction( + onInvoke: (intent) => pasteText(intent.cause))), + + HideSelectionToolbarIntent: + _makeOverridable(QuillEditorHideSelectionToolbarAction(this)), + UndoTextIntent: _makeOverridable(QuillEditorUndoKeyboardAction(this)), + RedoTextIntent: _makeOverridable(QuillEditorRedoKeyboardAction(this)), + + OpenSearchIntent: _openSearchAction, + + // Selection Formatting + ToggleTextStyleIntent: _formatSelectionAction, + IndentSelectionIntent: _indentSelectionAction, + QuillEditorApplyHeaderIntent: _applyHeaderAction, + QuillEditorApplyCheckListIntent: _applyCheckListAction, + QuillEditorApplyLinkIntent: QuillEditorApplyLinkAction(this) + }; + + @override + void insertTextPlaceholder(Size size) { + // this is needed for Scribble (Stylus input) in Apple platforms + // and this package does not implement this feature + } + + @override + void removeTextPlaceholder() { + // this is needed for Scribble (Stylus input) in Apple platforms + // and this package does not implement this feature + } + + @override + void didChangeInputControl( + TextInputControl? oldControl, + TextInputControl? newControl, + ) { + // TODO: implement didChangeInputControl + } + + @override + void performSelector(String selectorName) { + final intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } + } + + @override + // TODO: implement liveTextInputEnabled + bool get liveTextInputEnabled => false; +} diff --git a/lib/src/widgets/raw_editor/raw_editor_text_boundaries.dart b/lib/src/widgets/raw_editor/raw_editor_text_boundaries.dart new file mode 100644 index 00000000..d89d0665 --- /dev/null +++ b/lib/src/widgets/raw_editor/raw_editor_text_boundaries.dart @@ -0,0 +1,292 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show TextLayoutMetrics; + +/// An interface for retrieving the logical text boundary +/// (left-closed-right-open) +/// at a given location in a document. +/// +/// Depending on the implementation of the [QuillEditorTextBoundary], the input +/// [TextPosition] can either point to a code unit, or a position between 2 code +/// units (which can be visually represented by the caret if the selection were +/// to collapse to that position). +/// +/// For example, [QuillEditorLineBreak] interprets the input [TextPosition] as a caret +/// location, since in Flutter the caret is generally painted between the +/// character the [TextPosition] points to and its previous character, and +/// [QuillEditorLineBreak] cares about the affinity of the input [TextPosition]. Most +/// other text boundaries however, interpret the input [TextPosition] as the +/// location of a code unit in the document, since it's easier to reason about +/// the text boundary given a code unit in the text. +/// +/// To convert a "code-unit-based" [QuillEditorTextBoundary] to "caret-location-based", +/// use the [QuillEditorCollapsedSelectionBoundary] combinator. +abstract class QuillEditorTextBoundary { + const QuillEditorTextBoundary(); + + TextEditingValue get textEditingValue; + + /// Returns the leading text boundary at the given location, inclusive. + TextPosition getLeadingTextBoundaryAt(TextPosition position); + + /// Returns the trailing text boundary at the given location, exclusive. + TextPosition getTrailingTextBoundaryAt(TextPosition position); + + TextRange getTextBoundaryAt(TextPosition position) { + return TextRange( + start: getLeadingTextBoundaryAt(position).offset, + end: getTrailingTextBoundaryAt(position).offset, + ); + } +} + +// ----------------------------- Text Boundaries ----------------------------- + +// The word modifier generally removes the word boundaries around white spaces +// (and newlines), IOW white spaces and some other punctuations are considered +// a part of the next word in the search direction. +class QuillEditorWhitespaceBoundary extends QuillEditorTextBoundary { + const QuillEditorWhitespaceBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; index >= 0; index -= 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index); + } + } + return const TextPosition(offset: 0); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; + index < textEditingValue.text.length; + index += 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index + 1); + } + } + return TextPosition(offset: textEditingValue.text.length); + } +} + +// Most apps delete the entire grapheme when the backspace key is pressed. +// Also always put the new caret location to character boundaries to avoid +// sending malformed UTF-16 code units to the paragraph builder. +class QuillEditorCharacterBoundary extends QuillEditorTextBoundary { + const QuillEditorCharacterBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + return TextPosition( + offset: + CharacterRange.at(textEditingValue.text, position.offset, endOffset) + .stringBeforeLength, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextPosition( + offset: textEditingValue.text.length - range.stringAfterLength, + ); + } + + @override + TextRange getTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextRange( + start: range.stringBeforeLength, + end: textEditingValue.text.length - range.stringAfterLength, + ); + } +} + +// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. +class QuillEditorWordBoundary extends QuillEditorTextBoundary { + const QuillEditorWordBoundary(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).start, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).end, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } +} + +// The linebreaks of the current text layout. The input [TextPosition]s are +// interpreted as caret locations because [TextPainter.getLineAtOffset] is +// text-affinity-aware. +class QuillEditorLineBreak extends QuillEditorTextBoundary { + const QuillEditorLineBreak(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).start, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + } +} + +// The document boundary is unique and is a constant function of the input +// position. +class QuillEditorDocumentBoundary extends QuillEditorTextBoundary { + const QuillEditorDocumentBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + const TextPosition(offset: 0); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textEditingValue.text.length, + affinity: TextAffinity.upstream, + ); + } +} + +// ------------------------ Text Boundary Combinators ------------------------ + +// Expands the innerTextBoundary with outerTextBoundary. +class QuillEditorExpandedTextBoundary extends QuillEditorTextBoundary { + QuillEditorExpandedTextBoundary( + this.innerTextBoundary, this.outerTextBoundary); + + final QuillEditorTextBoundary innerTextBoundary; + final QuillEditorTextBoundary outerTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(innerTextBoundary.textEditingValue == + outerTextBoundary.textEditingValue); + return innerTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getLeadingTextBoundaryAt( + innerTextBoundary.getLeadingTextBoundaryAt(position), + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getTrailingTextBoundaryAt( + innerTextBoundary.getTrailingTextBoundaryAt(position), + ); + } +} + +// Force the innerTextBoundary to interpret the input [TextPosition]s as caret +// locations instead of code unit positions. +// +// The innerTextBoundary must be a [_TextBoundary] that interprets the input +// [TextPosition]s as code unit positions. +class QuillEditorCollapsedSelectionBoundary extends QuillEditorTextBoundary { + QuillEditorCollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); + + final QuillEditorTextBoundary innerTextBoundary; + final bool isForward; + + @override + TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getLeadingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getLeadingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getTrailingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getTrailingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } +} + +// A _TextBoundary that creates a [TextRange] where its start is from the +// specified leading text boundary and its end is from the specified trailing +// text boundary. +class QuillEditorMixedBoundary extends QuillEditorTextBoundary { + QuillEditorMixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); + + final QuillEditorTextBoundary leadingTextBoundary; + final QuillEditorTextBoundary trailingTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(leadingTextBoundary.textEditingValue == + trailingTextBoundary.textEditingValue); + return leadingTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + leadingTextBoundary.getLeadingTextBoundaryAt(position); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => + trailingTextBoundary.getTrailingTextBoundaryAt(position); +} diff --git a/lib/src/widgets/raw_editor/raw_editor_widget.dart b/lib/src/widgets/raw_editor/raw_editor_widget.dart new file mode 100644 index 00000000..a84dfa9e --- /dev/null +++ b/lib/src/widgets/raw_editor/raw_editor_widget.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ViewportOffset; + +import '../../models/documents/document.dart'; +import '../cursor.dart'; +import '../editor/editor.dart'; + +class QuilRawEditorMultiChildRenderObjectWidget + extends MultiChildRenderObjectWidget { + const QuilRawEditorMultiChildRenderObjectWidget({ + required super.children, + required this.document, + required this.textDirection, + required this.hasFocus, + required this.scrollable, + required this.selection, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.onSelectionChanged, + required this.onSelectionCompleted, + required this.scrollBottomInset, + required this.cursorController, + required this.floatingCursorDisabled, + super.key, + this.padding = EdgeInsets.zero, + this.maxContentWidth, + this.offset, + }); + + final ViewportOffset? offset; + final Document document; + final TextDirection textDirection; + final bool hasFocus; + final bool scrollable; + final TextSelection selection; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final TextSelectionChangedHandler onSelectionChanged; + final TextSelectionCompletedHandler onSelectionCompleted; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + final double? maxContentWidth; + final CursorCont cursorController; + final bool floatingCursorDisabled; + + @override + RenderEditor createRenderObject(BuildContext context) { + return RenderEditor( + offset: offset, + document: document, + textDirection: textDirection, + hasFocus: hasFocus, + scrollable: scrollable, + selection: selection, + startHandleLayerLink: startHandleLayerLink, + endHandleLayerLink: endHandleLayerLink, + onSelectionChanged: onSelectionChanged, + onSelectionCompleted: onSelectionCompleted, + cursorController: cursorController, + padding: padding, + maxContentWidth: maxContentWidth, + scrollBottomInset: scrollBottomInset, + floatingCursorDisabled: floatingCursorDisabled, + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant RenderEditor renderObject, + ) { + renderObject + ..offset = offset + ..document = document + ..setContainer(document.root) + ..textDirection = textDirection + ..setHasFocus(hasFocus) + ..setSelection(selection) + ..setStartHandleLayerLink(startHandleLayerLink) + ..setEndHandleLayerLink(endHandleLayerLink) + ..onSelectionChanged = onSelectionChanged + ..setScrollBottomInset(scrollBottomInset) + ..setPadding(padding) + ..maxContentWidth = maxContentWidth; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index cdc8db7c..fe135184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_quill description: A rich text editor built for the modern Android, iOS, web and desktop platforms. It is the WYSIWYG editor and a Quill component for Flutter. -version: 8.2.5 +version: 8.2.6 homepage: https://1o24bbs.com/c/bulletjournal/108 repository: https://github.com/singerdmx/flutter-quill @@ -31,7 +31,6 @@ platforms: environment: sdk: '>=3.1.5 <4.0.0' - # sdk: ">=2.17.0 <4.0.0" flutter: ">=3.10.0" dependencies: @@ -51,10 +50,6 @@ dependencies: flutter_animate: ^4.2.0+1 meta: ^1.9.1 -dependency_overrides: - flutter_quill_test: - path: ./flutter_quill_test - dev_dependencies: flutter_lints: ^3.0.1 flutter_test: diff --git a/pubspec_overrides.yaml.g b/pubspec_overrides.yaml.g new file mode 100644 index 00000000..0c0f849c --- /dev/null +++ b/pubspec_overrides.yaml.g @@ -0,0 +1,3 @@ +dependency_overrides: + flutter_quill_test: + path: ./flutter_quill_test \ No newline at end of file diff --git a/before-push.sh b/scripts/before-push.sh similarity index 100% rename from before-push.sh rename to scripts/before-push.sh diff --git a/scripts/disable_local_dev.sh b/scripts/disable_local_dev.sh new file mode 100755 index 00000000..b0d80b57 --- /dev/null +++ b/scripts/disable_local_dev.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +echo "" + +echo "Disable local development for flutter_quill:" +rm pubspec_overrides.yaml + +echo "" + +echo "Enable local development for flutter_quill_extensions:" +rm flutter_quill_extensions/pubspec_overrides.yaml + +echo "" + +echo "Enable local development for flutter_quill_test:" +rm flutter_quill_test/pubspec_overrides.yaml + +echo "" + +echo "Local development for all libraries has been disabled, please 'flutter pub get' for each one of them" \ No newline at end of file diff --git a/scripts/enable_local_dev.sh b/scripts/enable_local_dev.sh new file mode 100755 index 00000000..adafd237 --- /dev/null +++ b/scripts/enable_local_dev.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +echo "" + +echo "Enable local development for flutter_quill:" +cp pubspec_overrides.yaml.g pubspec_overrides.yaml + +echo "" + +echo "Enable local development for flutter_quill_extensions:" +cp flutter_quill_extensions/pubspec_overrides.yaml.g flutter_quill_extensions/pubspec_overrides.yaml + +echo "" + +echo "Enable local development for flutter_quill_test:" +cp flutter_quill_test/pubspec_overrides.yaml.g flutter_quill_test/pubspec_overrides.yaml + +echo "" + +echo "Local development for all libraries has been enabled, please 'flutter pub get' for each one of them" \ No newline at end of file