Rich text editor for Flutter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

2653 lines
86 KiB

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;
}