From f6223e062a00eed14138698ed0a0fa74100bcc83 Mon Sep 17 00:00:00 2001 From: X Code Date: Mon, 14 Feb 2022 07:20:04 -0800 Subject: [PATCH] Use actions to fix showing handler --- lib/src/widgets/editor.dart | 37 ++ lib/src/widgets/raw_editor.dart | 738 +++++++++++++++++++++++++++++++- lib/src/widgets/shortcut.dart | 99 +++++ 3 files changed, 868 insertions(+), 6 deletions(-) create mode 100644 lib/src/widgets/shortcut.dart diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 619bc2bb..5cbb6e4c 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -1544,6 +1544,14 @@ class RenderEditor extends RenderEditableContainerBox // End TextLayoutMetrics implementation + QuillVerticalCaretMovementRun startVerticalCaretMovement( + TextPosition startPosition) { + return QuillVerticalCaretMovementRun._( + this, + startPosition, + ); + } + @override void systemFontsDidChange() { super.systemFontsDidChange(); @@ -1551,6 +1559,35 @@ class RenderEditor extends RenderEditableContainerBox } } +class QuillVerticalCaretMovementRun + extends BidirectionalIterator { + QuillVerticalCaretMovementRun._( + this._editor, + this._currentTextPosition, + ); + + TextPosition _currentTextPosition; + + final RenderEditor _editor; + + @override + TextPosition get current { + return _currentTextPosition; + } + + @override + bool moveNext() { + _currentTextPosition = _editor.getTextPositionBelow(_currentTextPosition); + return true; + } + + @override + bool movePrevious() { + _currentTextPosition = _editor.getTextPositionAbove(_currentTextPosition); + return true; + } +} + class EditableContainerParentData extends ContainerBoxParentData {} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 39e3c8be..b15bc247 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -363,12 +363,15 @@ class RawEditorState extends EditorState return QuillStyles( data: _styles!, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: QuillKeyboardListener( - child: Container( - constraints: constraints, - child: child, + child: Actions( + actions: _actions, + child: Focus( + focusNode: widget.focusNode, + child: QuillKeyboardListener( + child: Container( + constraints: constraints, + child: child, + ), ), ), ), @@ -1006,6 +1009,123 @@ class RawEditorState extends EditorState _floatingCursorResetController; late AnimationController _floatingCursorResetController; + + // --------------------------- Text Editing Actions -------------------------- + + _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary = + _CharacterBoundary(textEditingValue); + return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward); + } + + _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + // final TextEditingValue textEditingValue = + // _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + // This isn't enough. Newline characters. + boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), + _WordBoundary(renderEditor, textEditingValue)); + + final mixedBoundary = intent.forward + ? _MixedBoundary(atomicTextBoundary, boundary) + : _MixedBoundary(boundary, atomicTextBoundary); + // Use a _MixedBoundary to make sure we don't leave invalid codepoints in + // the field after deletion. + return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); + } + + _TextBoundary _linebreak(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + // final TextEditingValue textEditingValue = + // _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + boundary = _LineBreak(renderEditor, textEditingValue); + + // The _MixedBoundary is to make sure we don't leave invalid code units in + // the field after deletion. + // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, + // since the document boundary is unique and the linebreak boundary is + // already caret-location based. + return intent.forward + ? _MixedBoundary( + _CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) + : _MixedBoundary( + boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false)); + } + + _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => + _DocumentBoundary(textEditingValue); + + Action _makeOverridable(Action defaultAction) { + return Action.overridable( + context: context, defaultAction: defaultAction); + } + + late final Action _replaceTextAction = + CallbackAction(onInvoke: _replaceText); + + void _updateSelection(UpdateSelectionIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.cause, + ); + } + + late final Action _updateSelectionAction = + CallbackAction(onInvoke: _updateSelection); + + late final _UpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = + _UpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent>(this); + + late final Map> _actions = >{ + DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), + ReplaceTextIntent: _replaceTextAction, + UpdateSelectionIntent: _updateSelectionAction, + DirectionalFocusIntent: DirectionalFocusAction.forTextField(), + + // Delete + DeleteCharacterIntent: _makeOverridable( + _DeleteTextAction(this, _characterBoundary)), + DeleteToNextWordBoundaryIntent: _makeOverridable( + _DeleteTextAction( + this, _nextWordBoundary)), + DeleteToLineBreakIntent: _makeOverridable( + _DeleteTextAction(this, _linebreak)), + + // Extend/Move Selection + ExtendSelectionByCharacterIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + false, + _characterBoundary, + )), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _nextWordBoundary)), + ExtendSelectionToLineBreakIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _linebreak)), + ExtendSelectionVerticallyToAdjacentLineIntent: + _makeOverridable(_adjacentLineAction), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _documentBoundary)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + _ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), + + // Copy Paste + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + PasteTextIntent: _makeOverridable(CallbackAction( + onInvoke: (intent) => pasteText(intent.cause))), + }; } class _Editor extends MultiChildRenderObjectWidget { @@ -1083,3 +1203,609 @@ class _Editor extends MultiChildRenderObjectWidget { ..maxContentWidth = maxContentWidth; } } + +/// An interface for retrieving the logical text boundary +/// (left-closed-right-open) +/// at a given location in a document. +/// +/// Depending on the implementation of the [_TextBoundary], the input +/// [TextPosition] can either point to a code unit, or a position between 2 code +/// units (which can be visually represented by the caret if the selection were +/// to collapse to that position). +/// +/// For example, [_LineBreak] interprets the input [TextPosition] as a caret +/// location, since in Flutter the caret is generally painted between the +/// character the [TextPosition] points to and its previous character, and +/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most +/// other text boundaries however, interpret the input [TextPosition] as the +/// location of a code unit in the document, since it's easier to reason about +/// the text boundary given a code unit in the text. +/// +/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based", +/// use the [_CollapsedSelectionBoundary] combinator. +abstract class _TextBoundary { + const _TextBoundary(); + + TextEditingValue get textEditingValue; + + /// Returns the leading text boundary at the given location, inclusive. + TextPosition getLeadingTextBoundaryAt(TextPosition position); + + /// Returns the trailing text boundary at the given location, exclusive. + TextPosition getTrailingTextBoundaryAt(TextPosition position); + + TextRange getTextBoundaryAt(TextPosition position) { + return TextRange( + start: getLeadingTextBoundaryAt(position).offset, + end: getTrailingTextBoundaryAt(position).offset, + ); + } +} + +// ----------------------------- Text Boundaries ----------------------------- + +// The word modifier generally removes the word boundaries around white spaces +// (and newlines), IOW white spaces and some other punctuations are considered +// a part of the next word in the search direction. +class _WhitespaceBoundary extends _TextBoundary { + const _WhitespaceBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; index >= 0; index -= 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index); + } + } + return const TextPosition(offset: 0); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; + index < textEditingValue.text.length; + index += 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index + 1); + } + } + return TextPosition(offset: textEditingValue.text.length); + } +} + +// Most apps delete the entire grapheme when the backspace key is pressed. +// Also always put the new caret location to character boundaries to avoid +// sending malformed UTF-16 code units to the paragraph builder. +class _CharacterBoundary extends _TextBoundary { + const _CharacterBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + return TextPosition( + offset: + CharacterRange.at(textEditingValue.text, position.offset, endOffset) + .stringBeforeLength, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextPosition( + offset: textEditingValue.text.length - range.stringAfterLength, + ); + } + + @override + TextRange getTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextRange( + start: range.stringBeforeLength, + end: textEditingValue.text.length - range.stringAfterLength, + ); + } +} + +// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. +class _WordBoundary extends _TextBoundary { + const _WordBoundary(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).start, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).end, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } +} + +// The linebreaks of the current text layout. The input [TextPosition]s are +// interpreted as caret locations because [TextPainter.getLineAtOffset] is +// text-affinity-aware. +class _LineBreak extends _TextBoundary { + const _LineBreak(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).start, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + } +} + +// The document boundary is unique and is a constant function of the input +// position. +class _DocumentBoundary extends _TextBoundary { + const _DocumentBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + const TextPosition(offset: 0); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textEditingValue.text.length, + affinity: TextAffinity.upstream, + ); + } +} + +// ------------------------ Text Boundary Combinators ------------------------ + +// Expands the innerTextBoundary with outerTextBoundary. +class _ExpandedTextBoundary extends _TextBoundary { + _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary); + + final _TextBoundary innerTextBoundary; + final _TextBoundary outerTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(innerTextBoundary.textEditingValue == + outerTextBoundary.textEditingValue); + return innerTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getLeadingTextBoundaryAt( + innerTextBoundary.getLeadingTextBoundaryAt(position), + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getTrailingTextBoundaryAt( + innerTextBoundary.getTrailingTextBoundaryAt(position), + ); + } +} + +// Force the innerTextBoundary to interpret the input [TextPosition]s as caret +// locations instead of code unit positions. +// +// The innerTextBoundary must be a [_TextBoundary] that interprets the input +// [TextPosition]s as code unit positions. +class _CollapsedSelectionBoundary extends _TextBoundary { + _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); + + final _TextBoundary innerTextBoundary; + final bool isForward; + + @override + TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getLeadingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getLeadingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getTrailingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getTrailingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } +} + +// A _TextBoundary that creates a [TextRange] where its start is from the +// specified leading text boundary and its end is from the specified trailing +// text boundary. +class _MixedBoundary extends _TextBoundary { + _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); + + final _TextBoundary leadingTextBoundary; + final _TextBoundary trailingTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(leadingTextBoundary.textEditingValue == + trailingTextBoundary.textEditingValue); + return leadingTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + leadingTextBoundary.getLeadingTextBoundaryAt(position); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => + trailingTextBoundary.getTrailingTextBoundaryAt(position); +} + +// ------------------------------- Text Actions ------------------------------- +class _DeleteTextAction + extends ContextAction { + _DeleteTextAction(this.state, this.getTextBoundariesForIntent); + + final 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 + extends ContextAction { + _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 { + _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.widget.controller.selection; + final continueCurrentRun = currentSelection.isValid && + currentSelection.isCollapsed && + currentSelection.baseOffset == runSelection.baseOffset && + currentSelection.extentOffset == runSelection.extentOffset; + if (!continueCurrentRun) { + _verticalMovementRun = null; + _runSelection = null; + } + } + + @override + void invoke(T intent, [BuildContext? context]) { + assert(state.textEditingValue.selection.isValid); + + final collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + final value = state.textEditingValue; + if (!value.selection.isValid) { + return; + } + + final currentRun = _verticalMovementRun ?? + state.renderEditor + .startVerticalCaretMovement(state.renderEditor.selection.extent); + + final shouldMove = + intent.forward ? currentRun.moveNext() : currentRun.movePrevious(); + final newExtent = shouldMove + ? currentRun.current + : (intent.forward + ? TextPosition(offset: state.textEditingValue.text.length) + : const TextPosition(offset: 0)); + final newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : value.selection.extendTo(newExtent); + + Actions.invoke( + context!, + UpdateSelectionIntent( + value, newSelection, SelectionChangedCause.keyboard), + ); + if (state.textEditingValue.selection == newSelection) { + _verticalMovementRun = currentRun; + _runSelection = newSelection; + } + } + + @override + bool get isActionEnabled => state.textEditingValue.selection.isValid; +} + +class _SelectAllAction extends ContextAction { + _SelectAllAction(this.state); + + final 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 { + _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; +} diff --git a/lib/src/widgets/shortcut.dart b/lib/src/widgets/shortcut.dart new file mode 100644 index 00000000..5cf28b8a --- /dev/null +++ b/lib/src/widgets/shortcut.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../models/documents/attribute.dart'; +import 'raw_editor.dart'; + +class QuillShortcuts extends Shortcuts { + QuillShortcuts({required Widget child, Key? key}) + : super( + key: key, + shortcuts: _shortcuts, + child: child, + ); + + static Map get _shortcuts { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return _defaultShortcuts; + case TargetPlatform.fuchsia: + return _defaultShortcuts; + case TargetPlatform.iOS: + return _macShortcuts; + case TargetPlatform.linux: + return _defaultShortcuts; + case TargetPlatform.macOS: + return _macShortcuts; + case TargetPlatform.windows: + return _defaultShortcuts; + } + } + + static const Map _defaultShortcuts = + { + SingleActivator(LogicalKeyboardKey.keyB, control: true): + ToggleBoldStyleIntent(), + SingleActivator(LogicalKeyboardKey.keyI, control: true): + ToggleItalicStyleIntent(), + SingleActivator(LogicalKeyboardKey.keyU, control: true): + ToggleUnderlineStyleIntent(), + }; + + static final Map _macShortcuts = + { + const SingleActivator(LogicalKeyboardKey.keyB, meta: true): + const ToggleBoldStyleIntent(), + const SingleActivator(LogicalKeyboardKey.keyI, meta: true): + const ToggleItalicStyleIntent(), + const SingleActivator(LogicalKeyboardKey.keyU, meta: true): + const ToggleUnderlineStyleIntent(), + }; +} + +class ToggleBoldStyleIntent extends Intent { + const ToggleBoldStyleIntent(); +} + +class ToggleItalicStyleIntent extends Intent { + const ToggleItalicStyleIntent(); +} + +class ToggleUnderlineStyleIntent extends Intent { + const ToggleUnderlineStyleIntent(); +} + +class QuillActions extends Actions { + QuillActions({ + required Widget child, + Key? key, + }) : super( + key: key, + actions: _shortcutsActions, + child: child, + ); + + static final Map> _shortcutsActions = + >{ + ToggleBoldStyleIntent: _ToggleInlineStyleAction(Attribute.bold), + ToggleItalicStyleIntent: _ToggleInlineStyleAction(Attribute.italic), + ToggleUnderlineStyleIntent: _ToggleInlineStyleAction(Attribute.underline), + }; +} + +class _ToggleInlineStyleAction extends ContextAction { + _ToggleInlineStyleAction(this.attribute); + + final Attribute attribute; + + @override + Object? invoke(Intent intent, [BuildContext? context]) { + final editorState = context!.findAncestorStateOfType()!; + final style = editorState.controller.getSelectionStyle(); + final actualAttr = style.containsKey(attribute.key) + ? Attribute.clone(attribute, null) + : attribute; + editorState.controller.formatSelection(actualAttr); + return null; + } +}