|
|
|
import 'dart:async';
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'dart:math' as math;
|
|
|
|
import 'dart:ui' as ui hide TextStyle;
|
|
|
|
|
|
|
|
import 'package:collection/collection.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
import 'package:flutter/scheduler.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
|
|
|
import 'package:pasteboard/pasteboard.dart';
|
|
|
|
|
|
|
|
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/documents/style.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/platform.dart';
|
|
|
|
import 'controller.dart';
|
|
|
|
import 'cursor.dart';
|
|
|
|
import 'default_styles.dart';
|
|
|
|
import 'delegate.dart';
|
|
|
|
import 'editor.dart';
|
|
|
|
import 'keyboard_listener.dart';
|
|
|
|
import 'link.dart';
|
|
|
|
import 'proxy.dart';
|
|
|
|
import 'quill_single_child_scroll_view.dart';
|
|
|
|
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
|
|
|
|
import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
|
|
|
|
import 'text_block.dart';
|
|
|
|
import 'text_line.dart';
|
|
|
|
import 'text_selection.dart';
|
|
|
|
import 'toolbar/link_style_button2.dart';
|
|
|
|
import 'toolbar/search_dialog.dart';
|
|
|
|
|
|
|
|
class RawEditor extends StatefulWidget {
|
|
|
|
const RawEditor({
|
|
|
|
required this.controller,
|
|
|
|
required this.focusNode,
|
|
|
|
required this.scrollController,
|
|
|
|
required this.scrollBottomInset,
|
|
|
|
required this.cursorStyle,
|
|
|
|
required this.selectionColor,
|
|
|
|
required this.selectionCtrls,
|
|
|
|
required this.embedBuilder,
|
|
|
|
Key? key,
|
|
|
|
this.scrollable = true,
|
|
|
|
this.padding = EdgeInsets.zero,
|
|
|
|
this.readOnly = false,
|
|
|
|
this.placeholder,
|
|
|
|
this.onLaunchUrl,
|
|
|
|
this.contextMenuBuilder = defaultContextMenuBuilder,
|
|
|
|
this.showSelectionHandles = false,
|
|
|
|
bool? showCursor,
|
|
|
|
this.textCapitalization = TextCapitalization.none,
|
|
|
|
this.maxHeight,
|
|
|
|
this.minHeight,
|
|
|
|
this.maxContentWidth,
|
|
|
|
this.customStyles,
|
|
|
|
this.customShortcuts,
|
|
|
|
this.customActions,
|
|
|
|
this.expands = false,
|
|
|
|
this.autoFocus = false,
|
|
|
|
this.enableUnfocusOnTapOutside = true,
|
|
|
|
this.keyboardAppearance = Brightness.light,
|
|
|
|
this.enableInteractiveSelection = true,
|
|
|
|
this.scrollPhysics,
|
|
|
|
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
|
|
|
|
this.customStyleBuilder,
|
|
|
|
this.customRecognizerBuilder,
|
|
|
|
this.floatingCursorDisabled = false,
|
|
|
|
this.onImagePaste,
|
|
|
|
this.customLinkPrefixes = const <String>[],
|
|
|
|
this.dialogTheme,
|
|
|
|
}) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
|
|
|
|
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
|
|
|
|
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
|
|
|
|
'maxHeight cannot be null'),
|
|
|
|
showCursor = showCursor ?? true,
|
|
|
|
super(key: key);
|
|
|
|
|
|
|
|
/// Controls the document being edited.
|
|
|
|
final QuillController controller;
|
|
|
|
|
|
|
|
/// Controls whether this editor has keyboard focus.
|
|
|
|
final FocusNode focusNode;
|
|
|
|
final ScrollController scrollController;
|
|
|
|
final bool scrollable;
|
|
|
|
final double scrollBottomInset;
|
|
|
|
final bool enableUnfocusOnTapOutside;
|
|
|
|
|
|
|
|
/// Additional space around the editor contents.
|
|
|
|
final EdgeInsetsGeometry padding;
|
|
|
|
|
|
|
|
/// Whether the text can be changed.
|
|
|
|
///
|
|
|
|
/// When this is set to true, the text cannot be modified
|
|
|
|
/// by any shortcut or keyboard operation. The text is still selectable.
|
|
|
|
///
|
|
|
|
/// Defaults to false. Must not be null.
|
|
|
|
final bool readOnly;
|
|
|
|
|
|
|
|
final String? placeholder;
|
|
|
|
|
|
|
|
/// Callback which is triggered when the user wants to open a URL from
|
|
|
|
/// a link in the document.
|
|
|
|
final ValueChanged<String>? onLaunchUrl;
|
|
|
|
|
|
|
|
/// Builds the text selection toolbar when requested by the user.
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
/// * [EditableText.contextMenuBuilder], which builds the default
|
|
|
|
/// text selection toolbar for [EditableText].
|
|
|
|
///
|
|
|
|
/// If not provided, no context menu will be shown.
|
|
|
|
final QuillEditorContextMenuBuilder? contextMenuBuilder;
|
|
|
|
|
|
|
|
static Widget defaultContextMenuBuilder(
|
|
|
|
BuildContext context,
|
|
|
|
RawEditorState state,
|
|
|
|
) {
|
|
|
|
return TextFieldTapRegion(
|
|
|
|
child: AdaptiveTextSelectionToolbar.buttonItems(
|
|
|
|
buttonItems: state.contextMenuButtonItems,
|
|
|
|
anchors: state.contextMenuAnchors,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Whether to show selection handles.
|
|
|
|
///
|
|
|
|
/// When a selection is active, there will be two handles at each side of
|
|
|
|
/// boundary, or one handle if the selection is collapsed. The handles can be
|
|
|
|
/// dragged to adjust the selection.
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
///
|
|
|
|
/// * [showCursor], which controls the visibility of the cursor.
|
|
|
|
final bool showSelectionHandles;
|
|
|
|
|
|
|
|
/// Whether to show cursor.
|
|
|
|
///
|
|
|
|
/// The cursor refers to the blinking caret when the editor is focused.
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
///
|
|
|
|
/// * [cursorStyle], which controls the cursor visual representation.
|
|
|
|
/// * [showSelectionHandles], which controls the visibility of the selection
|
|
|
|
/// handles.
|
|
|
|
final bool showCursor;
|
|
|
|
|
|
|
|
/// The style to be used for the editing cursor.
|
|
|
|
final CursorStyle cursorStyle;
|
|
|
|
|
|
|
|
/// Configures how the platform keyboard will select an uppercase or
|
|
|
|
/// lowercase keyboard.
|
|
|
|
///
|
|
|
|
/// Only supports text keyboards, other keyboard types will ignore this
|
|
|
|
/// configuration. Capitalization is locale-aware.
|
|
|
|
///
|
|
|
|
/// Defaults to [TextCapitalization.none]. Must not be null.
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
///
|
|
|
|
/// * [TextCapitalization], for a description of each capitalization behavior
|
|
|
|
final TextCapitalization textCapitalization;
|
|
|
|
|
|
|
|
/// The maximum height this editor can have.
|
|
|
|
///
|
|
|
|
/// If this is null then there is no limit to the editor's height and it will
|
|
|
|
/// expand to fill its parent.
|
|
|
|
final double? maxHeight;
|
|
|
|
|
|
|
|
/// The minimum height this editor can have.
|
|
|
|
final double? minHeight;
|
|
|
|
|
|
|
|
/// The maximum width to be occupied by the content of this editor.
|
|
|
|
///
|
|
|
|
/// If this is not null and and this editor's width is larger than this value
|
|
|
|
/// then the contents will be constrained to the provided maximum width and
|
|
|
|
/// horizontally centered. This is mostly useful on devices with wide screens.
|
|
|
|
final double? maxContentWidth;
|
|
|
|
|
|
|
|
/// Allows to override [DefaultStyles].
|
|
|
|
final DefaultStyles? customStyles;
|
|
|
|
|
|
|
|
/// Whether this widget's height will be sized to fill its parent.
|
|
|
|
///
|
|
|
|
/// If set to true and wrapped in a parent widget like [Expanded] or
|
|
|
|
///
|
|
|
|
/// Defaults to false.
|
|
|
|
final bool expands;
|
|
|
|
|
|
|
|
/// Whether this editor should focus itself if nothing else is already
|
|
|
|
/// focused.
|
|
|
|
///
|
|
|
|
/// If true, the keyboard will open as soon as this text field obtains focus.
|
|
|
|
/// Otherwise, the keyboard is only shown after the user taps the text field.
|
|
|
|
///
|
|
|
|
/// Defaults to false. Cannot be null.
|
|
|
|
final bool autoFocus;
|
|
|
|
|
|
|
|
/// The color to use when painting the selection.
|
|
|
|
final Color selectionColor;
|
|
|
|
|
|
|
|
/// Delegate for building the text selection handles and toolbar.
|
|
|
|
///
|
|
|
|
/// The [RawEditor] widget used on its own will not trigger the display
|
|
|
|
/// of the selection toolbar by itself. The toolbar is shown by calling
|
|
|
|
/// [RawEditorState.showToolbar] in response to an appropriate user event.
|
|
|
|
final TextSelectionControls selectionCtrls;
|
|
|
|
|
|
|
|
/// The appearance of the keyboard.
|
|
|
|
///
|
|
|
|
/// This setting is only honored on iOS devices.
|
|
|
|
///
|
|
|
|
/// Defaults to [Brightness.light].
|
|
|
|
final Brightness keyboardAppearance;
|
|
|
|
|
|
|
|
/// If true, then long-pressing this TextField will select text and show the
|
|
|
|
/// cut/copy/paste menu, and tapping will move the text caret.
|
|
|
|
///
|
|
|
|
/// True by default.
|
|
|
|
///
|
|
|
|
/// If false, most of the accessibility support for selecting text, copy
|
|
|
|
/// and paste, and moving the caret will be disabled.
|
|
|
|
final bool enableInteractiveSelection;
|
|
|
|
|
|
|
|
bool get selectionEnabled => enableInteractiveSelection;
|
|
|
|
|
|
|
|
/// The [ScrollPhysics] to use when vertically scrolling the input.
|
|
|
|
///
|
|
|
|
/// If not specified, it will behave according to the current platform.
|
|
|
|
///
|
|
|
|
/// See [Scrollable.physics].
|
|
|
|
final ScrollPhysics? scrollPhysics;
|
|
|
|
|
|
|
|
final Future<String?> Function(Uint8List imageBytes)? onImagePaste;
|
|
|
|
|
|
|
|
/// Contains user-defined shortcuts map.
|
|
|
|
///
|
|
|
|
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts]
|
|
|
|
final Map<ShortcutActivator, Intent>? customShortcuts;
|
|
|
|
|
|
|
|
/// Contains user-defined actions.
|
|
|
|
///
|
|
|
|
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions]
|
|
|
|
final Map<Type, Action<Intent>>? customActions;
|
|
|
|
|
|
|
|
/// Builder function for embeddable objects.
|
|
|
|
final EmbedsBuilder embedBuilder;
|
|
|
|
final LinkActionPickerDelegate linkActionPickerDelegate;
|
|
|
|
final CustomStyleBuilder? customStyleBuilder;
|
|
|
|
final CustomRecognizerBuilder? customRecognizerBuilder;
|
|
|
|
final bool floatingCursorDisabled;
|
|
|
|
final List<String> customLinkPrefixes;
|
|
|
|
|
|
|
|
/// Configures the dialog theme.
|
|
|
|
final QuillDialogTheme? dialogTheme;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<StatefulWidget> createState() => RawEditorState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class RawEditorState extends EditorState
|
|
|
|
with
|
|
|
|
AutomaticKeepAliveClientMixin<RawEditor>,
|
|
|
|
WidgetsBindingObserver,
|
|
|
|
TickerProviderStateMixin<RawEditor>,
|
|
|
|
RawEditorStateTextInputClientMixin,
|
|
|
|
RawEditorStateSelectionDelegateMixin {
|
|
|
|
final GlobalKey _editorKey = GlobalKey();
|
|
|
|
|
|
|
|
KeyboardVisibilityController? _keyboardVisibilityController;
|
|
|
|
StreamSubscription<bool>? _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<OffsetValue<Style>> get pasteStyle => _pasteStyle;
|
|
|
|
List<OffsetValue<Style>> _pasteStyle = <OffsetValue<Style>>[];
|
|
|
|
|
|
|
|
@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
|
|
|
|
void insertContent(KeyboardInsertedContent content) {}
|
|
|
|
|
|
|
|
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
|
|
|
|
/// platform's default selection menu for [RawEditor].
|
|
|
|
///
|
|
|
|
/// Copied from [EditableTextState].
|
|
|
|
List<ContextMenuButtonItem> get contextMenuButtonItems {
|
|
|
|
return EditableText.getEditableButtonItems(
|
|
|
|
clipboardStatus: _clipboardStatus.value,
|
|
|
|
onCopy: copyEnabled
|
|
|
|
? () => copySelection(SelectionChangedCause.toolbar)
|
|
|
|
: null,
|
|
|
|
onCut:
|
|
|
|
cutEnabled ? () => cutSelection(SelectionChangedCause.toolbar) : null,
|
|
|
|
onPaste:
|
|
|
|
pasteEnabled ? () => pasteText(SelectionChangedCause.toolbar) : null,
|
|
|
|
onSelectAll: selectAllEnabled
|
|
|
|
? () => selectAll(SelectionChangedCause.toolbar)
|
|
|
|
: null,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Returns the anchor points for the default context menu.
|
|
|
|
///
|
|
|
|
/// Copied from [EditableTextState].
|
|
|
|
TextSelectionToolbarAnchors get contextMenuAnchors {
|
|
|
|
final glyphHeights = _getGlyphHeights();
|
|
|
|
final selection = textEditingValue.selection;
|
|
|
|
final points = renderEditor.getEndpointsForSelection(selection);
|
|
|
|
return TextSelectionToolbarAnchors.fromSelection(
|
|
|
|
renderBox: renderEditor,
|
|
|
|
startGlyphHeight: glyphHeights.startGlyphHeight,
|
|
|
|
endGlyphHeight: glyphHeights.endGlyphHeight,
|
|
|
|
selectionEndpoints: points,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Gets the line heights at the start and end of the selection for the given
|
|
|
|
/// [RawEditorState].
|
|
|
|
///
|
|
|
|
/// 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) {
|
|
|
|
/// 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:
|
|
|
|
if (kIsWeb) {
|
|
|
|
widget.focusNode.unfocus();
|
|
|
|
}
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@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: _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: _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);
|
|
|
|
|
|
|
|
final isMacOS = Theme.of(context).platform == TargetPlatform.macOS;
|
|
|
|
|
|
|
|
return TextFieldTapRegion(
|
|
|
|
enabled: widget.enableUnfocusOnTapOutside,
|
|
|
|
onTapOutside: _defaultOnTapOutside,
|
|
|
|
child: QuillStyles(
|
|
|
|
data: _styles!,
|
|
|
|
child: Shortcuts(
|
|
|
|
shortcuts: mergeMaps<ShortcutActivator, Intent>({
|
|
|
|
// shortcuts added for Desktop platforms.
|
|
|
|
const SingleActivator(
|
|
|
|
LogicalKeyboardKey.escape,
|
|
|
|
): const HideSelectionToolbarIntent(),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyZ,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const UndoTextIntent(SelectionChangedCause.keyboard),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyY,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const RedoTextIntent(SelectionChangedCause.keyboard),
|
|
|
|
|
|
|
|
// Selection formatting.
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyB,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.bold),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyU,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.underline),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyI,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.italic),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyS,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.strikeThrough),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.backquote,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.inlineCode),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.tilde,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.codeBlock),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyB,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.blockQuote),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyK,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ApplyLinkIntent(),
|
|
|
|
|
|
|
|
// Lists
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyL,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.ul),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyO,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const ToggleTextStyleIntent(Attribute.ol),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyC,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const ApplyCheckListIntent(),
|
|
|
|
|
|
|
|
// Indents
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyM,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const IndentSelectionIntent(true),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyM,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
shift: true,
|
|
|
|
): const IndentSelectionIntent(false),
|
|
|
|
|
|
|
|
// Headers
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.digit1,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ApplyHeaderIntent(Attribute.h1),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.digit2,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ApplyHeaderIntent(Attribute.h2),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.digit3,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ApplyHeaderIntent(Attribute.h3),
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.digit0,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const ApplyHeaderIntent(Attribute.header),
|
|
|
|
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyG,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const InsertEmbedIntent(Attribute.image),
|
|
|
|
|
|
|
|
SingleActivator(
|
|
|
|
LogicalKeyboardKey.keyF,
|
|
|
|
control: !isMacOS,
|
|
|
|
meta: isMacOS,
|
|
|
|
): const OpenSearchIntent(),
|
|
|
|
}, {
|
|
|
|
...?widget.customShortcuts
|
|
|
|
}),
|
|
|
|
child: Actions(
|
|
|
|
actions: mergeMaps<Type, Action<Intent>>(_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<leaf.Text>(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() {
|
|
|
|
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.Text)) {
|
|
|
|
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.Text).value.isNotEmpty &&
|
|
|
|
controller.selection.base.offset > node.documentOffset) {
|
|
|
|
return insertTabCharacter();
|
|
|
|
}
|
|
|
|
controller.indentSelection(!event.isShiftPressed);
|
|
|
|
return KeyEventResult.handled;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (node.isNotEmpty && (node.first as leaf.Text).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) {
|
|
|
|
if (!widget.readOnly) {
|
|
|
|
_disableScrollControllerAnimateOnce = true;
|
|
|
|
final currentSelection = controller.selection.copyWith();
|
|
|
|
final attribute = value ? Attribute.checked : Attribute.unchecked;
|
|
|
|
|
|
|
|
controller
|
|
|
|
..ignoreFocusOnTextChange = true
|
|
|
|
..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
|
|
|
|
..updateSelection(currentSelection, ChangeSource.LOCAL);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
List<Widget> _buildChildren(Document doc, BuildContext context) {
|
|
|
|
final result = <Widget>[];
|
|
|
|
final indentLevelCounts = <int, int>{};
|
|
|
|
// 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: _textDirection,
|
|
|
|
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 {
|
|
|
|
throw StateError('Unreachable.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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.of(context).devicePixelRatio,
|
|
|
|
_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 '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);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
|
|
|
|
_clipboardStatus.addListener(_onChangedClipboardStatus);
|
|
|
|
|
|
|
|
controller.addListener(() {
|
|
|
|
_didChangeTextEditingValue(controller.ignoreFocusOnTextChange);
|
|
|
|
});
|
|
|
|
|
|
|
|
_scrollController = widget.scrollController;
|
|
|
|
_scrollController.addListener(_updateSelectionOverlayForScroll);
|
|
|
|
|
|
|
|
_cursorCont = CursorCont(
|
|
|
|
show: ValueNotifier<bool>(widget.showCursor),
|
|
|
|
style: widget.cursorStyle,
|
|
|
|
tickerProvider: this,
|
|
|
|
);
|
|
|
|
|
|
|
|
// Floating cursor
|
|
|
|
_floatingCursorResetController = AnimationController(vsync: this);
|
|
|
|
_floatingCursorResetController.addListener(onFloatingCursorResetTick);
|
|
|
|
|
|
|
|
if (isKeyboardOS()) {
|
|
|
|
_keyboardVisible = true;
|
|
|
|
} else if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) {
|
|
|
|
// treat tests like a keyboard OS
|
|
|
|
_keyboardVisible = true;
|
|
|
|
} else {
|
|
|
|
// treat iOS Simulator like a keyboard OS
|
|
|
|
isIOSSimulator().then((isIosSimulator) {
|
|
|
|
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!);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_didAutoFocus && widget.autoFocus) {
|
|
|
|
FocusScope.of(context).autofocus(widget.focusNode);
|
|
|
|
_didAutoFocus = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void didUpdateWidget(RawEditor 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(_didChangeTextEditingValue);
|
|
|
|
widget.focusNode.removeListener(_handleFocusChanged);
|
|
|
|
_cursorCont.dispose();
|
|
|
|
_clipboardStatus
|
|
|
|
..removeListener(_onChangedClipboardStatus)
|
|
|
|
..dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _updateSelectionOverlayForScroll() {
|
|
|
|
_selectionOverlay?.updateForScroll();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _didChangeTextEditingValue([bool ignoreFocus = false]) {
|
|
|
|
if (kIsWeb) {
|
|
|
|
_onChangeTextEditingValue(ignoreFocus);
|
|
|
|
if (!ignoreFocus) {
|
|
|
|
requestKeyboard();
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (ignoreFocus || _keyboardVisible) {
|
|
|
|
_onChangeTextEditingValue(ignoreFocus);
|
|
|
|
} else {
|
|
|
|
requestKeyboard();
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
// Use controller.value in build()
|
|
|
|
// Trigger build and updateChildren
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_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) {
|
|
|
|
setState(() {
|
|
|
|
// Use controller.value in build()
|
|
|
|
// Trigger build and updateChildren
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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() {
|
|
|
|
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;
|
|
|
|
setState(() {
|
|
|
|
// Inform the widget that the value of clipboardStatus has changed.
|
|
|
|
// Trigger build and updateChildren
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<LinkMenuAction> _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) {
|
|
|
|
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 (kIsWeb) {
|
|
|
|
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();
|
|
|
|
_pasteStyle = controller.getAllIndividualSelectionStyles();
|
|
|
|
|
|
|
|
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();
|
|
|
|
_pasteStyle = controller.getAllIndividualSelectionStyles();
|
|
|
|
|
|
|
|
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<void> 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;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (widget.onImagePaste != null) {
|
|
|
|
final image = await Pasteboard.image;
|
|
|
|
|
|
|
|
if (image == null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
final imageUrl = await widget.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<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
|
|
|
|
return Action<T>.overridable(
|
|
|
|
context: context, defaultAction: defaultAction);
|
|
|
|
}
|
|
|
|
|
|
|
|
late final Action<ReplaceTextIntent> _replaceTextAction =
|
|
|
|
CallbackAction<ReplaceTextIntent>(onInvoke: _replaceText);
|
|
|
|
|
|
|
|
void _updateSelection(UpdateSelectionIntent intent) {
|
|
|
|
userUpdateTextEditingValue(
|
|
|
|
intent.currentTextEditingValue.copyWith(selection: intent.newSelection),
|
|
|
|
intent.cause,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
late final Action<UpdateSelectionIntent> _updateSelectionAction =
|
|
|
|
CallbackAction<UpdateSelectionIntent>(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<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
|
|
|
|
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
|
|
|
|
ReplaceTextIntent: _replaceTextAction,
|
|
|
|
UpdateSelectionIntent: _updateSelectionAction,
|
|
|
|
DirectionalFocusIntent: DirectionalFocusAction.forTextField(),
|
|
|
|
|
|
|
|
// Delete
|
|
|
|
DeleteCharacterIntent: _makeOverridable(
|
|
|
|
_DeleteTextAction<DeleteCharacterIntent>(this, _characterBoundary)),
|
|
|
|
DeleteToNextWordBoundaryIntent: _makeOverridable(
|
|
|
|
_DeleteTextAction<DeleteToNextWordBoundaryIntent>(
|
|
|
|
this, _nextWordBoundary)),
|
|
|
|
DeleteToLineBreakIntent: _makeOverridable(
|
|
|
|
_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)),
|
|
|
|
|
|
|
|
// Extend/Move Selection
|
|
|
|
ExtendSelectionByCharacterIntent: _makeOverridable(
|
|
|
|
_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(
|
|
|
|
this,
|
|
|
|
false,
|
|
|
|
_characterBoundary,
|
|
|
|
)),
|
|
|
|
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(
|
|
|
|
_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(
|
|
|
|
this, true, _nextWordBoundary)),
|
|
|
|
ExtendSelectionToLineBreakIntent: _makeOverridable(
|
|
|
|
_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(
|
|
|
|
this, true, _linebreak)),
|
|
|
|
ExtendSelectionVerticallyToAdjacentLineIntent:
|
|
|
|
_makeOverridable(_adjacentLineAction),
|
|
|
|
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(
|
|
|
|
_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(
|
|
|
|
this, true, _documentBoundary)),
|
|
|
|
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(
|
|
|
|
_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)),
|
|
|
|
|
|
|
|
// Copy Paste
|
|
|
|
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
|
|
|
|
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
|
|
|
|
PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _Editor extends MultiChildRenderObjectWidget {
|
|
|
|
const _Editor({
|
|
|
|
required Key key,
|
|
|
|
required List<Widget> 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,
|
|
|
|
}) : super(key: key, children: children);
|
|
|
|
|
|
|
|
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<T extends DirectionalTextEditingIntent>
|
|
|
|
extends ContextAction<T> {
|
|
|
|
_DeleteTextAction(this.state, this.getTextBoundariesForIntent);
|
|
|
|
|
|
|
|
final RawEditorState 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<T extends DirectionalCaretMovementIntent>
|
|
|
|
extends ContextAction<T> {
|
|
|
|
_UpdateTextSelectionAction(this.state, this.ignoreNonCollapsedSelection,
|
|
|
|
this.getTextBoundariesForIntent);
|
|
|
|
|
|
|
|
final RawEditorState 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 RawEditorState 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<T> {
|
|
|
|
_UpdateTextSelectionToAdjacentLineAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState 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<SelectAllTextIntent> {
|
|
|
|
_SelectAllAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState 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<CopySelectionTextIntent> {
|
|
|
|
_CopySelectionAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState 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<HideSelectionToolbarIntent> {
|
|
|
|
_HideSelectionToolbarAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState state;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void invoke(HideSelectionToolbarIntent intent, [BuildContext? context]) {
|
|
|
|
state.hideToolbar();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
bool get isActionEnabled => state.textEditingValue.selection.isValid;
|
|
|
|
}
|
|
|
|
|
|
|
|
class _UndoKeyboardAction extends ContextAction<UndoTextIntent> {
|
|
|
|
_UndoKeyboardAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState state;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void invoke(UndoTextIntent intent, [BuildContext? context]) {
|
|
|
|
if (state.controller.hasUndo) {
|
|
|
|
state.controller.undo();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
bool get isActionEnabled => true;
|
|
|
|
}
|
|
|
|
|
|
|
|
class _RedoKeyboardAction extends ContextAction<RedoTextIntent> {
|
|
|
|
_RedoKeyboardAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState 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<ToggleTextStyleIntent> {
|
|
|
|
_ToggleTextStyleAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState state;
|
|
|
|
|
|
|
|
bool _isStyleActive(Attribute styleAttr, Map<String, Attribute> 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<IndentSelectionIntent> {
|
|
|
|
_IndentSelectionAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState 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<OpenSearchIntent> {
|
|
|
|
_OpenSearchAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState state;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Future invoke(OpenSearchIntent intent, [BuildContext? context]) async {
|
|
|
|
await showDialog<String>(
|
|
|
|
context: context!,
|
|
|
|
builder: (_) => SearchDialog(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<ApplyHeaderIntent> {
|
|
|
|
_ApplyHeaderAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState state;
|
|
|
|
|
|
|
|
Attribute<dynamic> _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<ApplyCheckListIntent> {
|
|
|
|
_ApplyCheckListAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState 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<ApplyLinkIntent> {
|
|
|
|
ApplyLinkAction(this.state);
|
|
|
|
|
|
|
|
final RawEditorState state;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Object? invoke(ApplyLinkIntent intent) async {
|
|
|
|
final initialTextLink = QuillTextLink.prepare(state.controller);
|
|
|
|
|
|
|
|
final textLink = await showDialog<QuillTextLink>(
|
|
|
|
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
|
|
|
|
/// [RawEditorState].
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
///
|
|
|
|
/// * [EditableTextContextMenuBuilder], which performs the same role for
|
|
|
|
/// [EditableText]
|
|
|
|
typedef QuillEditorContextMenuBuilder = Widget Function(
|
|
|
|
BuildContext context,
|
|
|
|
RawEditorState rawEditorState,
|
|
|
|
);
|
|
|
|
|
|
|
|
class _GlyphHeights {
|
|
|
|
_GlyphHeights(
|
|
|
|
this.startGlyphHeight,
|
|
|
|
this.endGlyphHeight,
|
|
|
|
);
|
|
|
|
|
|
|
|
final double startGlyphHeight;
|
|
|
|
final double endGlyphHeight;
|
|
|
|
}
|