diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md index ff759a4b..077b59c0 100644 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -13,4 +13,4 @@ My issue is about [Desktop] I have tried running `example` directory successfully before creating an issue here. -Please note that we are using stable channel. If you are using beta or master channel, those are not supported. +Please note that we are using stable channel on branch master. If you are using beta or master channel, use branch dev. diff --git a/CHANGELOG.md b/CHANGELOG.md index d96c8386..323da506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.3.0] +* Support flutter 2.2.0. + +## [1.2.2] +* Checkbox supports tapping. + ## [1.2.1] * Indented position not holding while editing. diff --git a/lib/models/documents/document.dart b/lib/models/documents/document.dart index 68dbee4d..9d966f32 100644 --- a/lib/models/documents/document.dart +++ b/lib/models/documents/document.dart @@ -40,6 +40,10 @@ class Document { final Rules _rules = Rules.getInstance(); + void setCustomRules(List customRules) { + _rules.setCustomRules(customRules); + } + final StreamController> _observer = StreamController.broadcast(); @@ -47,7 +51,7 @@ class Document { Stream> get changes => _observer.stream; - Delta insert(int index, Object? data, {int replaceLength = 0}) { + Delta insert(int index, Object? data, {int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) { assert(index >= 0); assert(data is String || data is Embeddable); if (data is Embeddable) { @@ -58,7 +62,7 @@ class Document { final delta = _rules.apply(RuleType.INSERT, this, index, data: data, len: replaceLength); - compose(delta, ChangeSource.LOCAL); + compose(delta, ChangeSource.LOCAL, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); return delta; } @@ -71,7 +75,7 @@ class Document { return delta; } - Delta replace(int index, int len, Object? data) { + Delta replace(int index, int len, Object? data, {bool autoAppendNewlineAfterImage = true}) { assert(index >= 0); assert(data is String || data is Embeddable); @@ -84,7 +88,8 @@ class Document { // We have to insert before applying delete rules // Otherwise delete would be operating on stale document snapshot. if (dataIsNotEmpty) { - delta = insert(index, data, replaceLength: len); + delta = insert(index, data, replaceLength: len, + autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); } if (len > 0) { @@ -124,13 +129,13 @@ class Document { return block.queryChild(res.offset, true); } - void compose(Delta delta, ChangeSource changeSource) { + void compose(Delta delta, ChangeSource changeSource, {bool autoAppendNewlineAfterImage = true}) { assert(!_observer.isClosed); delta.trim(); assert(delta.isNotEmpty); var offset = 0; - delta = _transform(delta); + delta = _transform(delta, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); final originalDelta = toDelta(); for (final op in delta.toList()) { final style = @@ -174,22 +179,28 @@ class Document { bool get hasRedo => _history.hasRedo; - static Delta _transform(Delta delta) { + static Delta _transform(Delta delta, {bool autoAppendNewlineAfterImage = true}) { final res = Delta(); final ops = delta.toList(); for (var i = 0; i < ops.length; i++) { final op = ops[i]; res.push(op); - _handleImageInsert(i, ops, op, res); + if (autoAppendNewlineAfterImage) { + _autoAppendNewlineAfterImage(i, ops, op, res); + } } return res; } - static void _handleImageInsert( + static void _autoAppendNewlineAfterImage( int i, List ops, Operation op, Delta res) { final nextOpIsImage = i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String; - if (nextOpIsImage && !(op.data as String).endsWith('\n')) { + if (nextOpIsImage && + op.data is String && + (op.data as String).isNotEmpty && + !(op.data as String).endsWith('\n')) + { res.push(Operation.insert('\n')); } // Currently embed is equivalent to image and hence `is! String` diff --git a/lib/models/rules/rule.dart b/lib/models/rules/rule.dart index 4ee6c278..042f1aaa 100644 --- a/lib/models/rules/rule.dart +++ b/lib/models/rules/rule.dart @@ -28,6 +28,8 @@ abstract class Rule { class Rules { Rules(this._rules); + List _customRules = []; + final List _rules; static final Rules _instance = Rules([ const FormatLinkAtCaretPositionRule(), @@ -49,10 +51,14 @@ class Rules { static Rules getInstance() => _instance; + void setCustomRules(List customRules) { + _customRules = customRules; + } + Delta apply(RuleType ruleType, Document document, int index, {int? len, Object? data, Attribute? attribute}) { final delta = document.toDelta(); - for (final rule in _rules) { + for (final rule in _customRules + _rules) { if (rule.type != ruleType) { continue; } diff --git a/lib/widgets/controller.dart b/lib/widgets/controller.dart index cb5136df..4820b3f9 100644 --- a/lib/widgets/controller.dart +++ b/lib/widgets/controller.dart @@ -92,12 +92,12 @@ class QuillController extends ChangeNotifier { void replaceText( int index, int len, Object? data, TextSelection? textSelection, - {bool ignoreFocus = false}) { + {bool ignoreFocus = false, bool autoAppendNewlineAfterImage = true}) { assert(data is String || data is Embeddable); Delta? delta; if (len > 0 || data is! String || data.isNotEmpty) { - delta = document.replace(index, len, data); + delta = document.replace(index, len, data, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); var shouldRetainDelta = toggledStyle.isNotEmpty && delta.isNotEmpty && delta.length <= 2 && diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index f021b88a..662a018c 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -393,8 +393,6 @@ class _QuillEditorSelectionGestureDetectorBuilder final segmentResult = line.queryChild(result.offset, false); if (segmentResult.node == null) { if (line.length == 1) { - // tapping when no text yet on this line - _flipListCheckbox(pos, line, segmentResult); getEditor()!.widget.controller.updateSelection( TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); return true; @@ -434,37 +432,9 @@ class _QuillEditorSelectionGestureDetectorBuilder ), ); } - return false; - } - if (_flipListCheckbox(pos, line, segmentResult)) { - return true; } - return false; - } - bool _flipListCheckbox( - TextPosition pos, Line line, container_node.ChildQuery segmentResult) { - if (getEditor()!.widget.readOnly || - !line.style.containsKey(Attribute.list.key) || - segmentResult.offset != 0) { - return false; - } - // segmentResult.offset == 0 means tap at the beginning of the TextLine - final String? listVal = line.style.attributes[Attribute.list.key]!.value; - if (listVal == Attribute.unchecked.value) { - getEditor()! - .widget - .controller - .formatText(pos.offset, 0, Attribute.checked); - } else if (listVal == Attribute.checked.value) { - getEditor()! - .widget - .controller - .formatText(pos.offset, 0, Attribute.unchecked); - } - getEditor()!.widget.controller.updateSelection( - TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); - return true; + return false; } Future _launchUrl(String url) async { diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index 022a140d..37c22819 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -15,7 +15,6 @@ import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/line.dart'; -import '../utils/diff_delta.dart'; import 'controller.dart'; import 'cursor.dart'; import 'default_styles.dart'; @@ -23,6 +22,9 @@ import 'delegate.dart'; import 'editor.dart'; import 'keyboard_listener.dart'; import 'proxy.dart'; +import 'raw_editor/raw_editor_state_keyboard_mixin.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'; @@ -89,406 +91,55 @@ class RawEditor extends StatefulWidget { final EmbedBuilder embedBuilder; @override - State createState() { - return RawEditorState(); - } + State createState() => RawEditorState(); } class RawEditorState extends EditorState with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, - TickerProviderStateMixin - implements TextSelectionDelegate, TextInputClient { + TickerProviderStateMixin, + RawEditorStateKeyboardMixin, + RawEditorStateTextInputClientMixin, + RawEditorStateSelectionDelegateMixin { final GlobalKey _editorKey = GlobalKey(); - final List _sentRemoteValues = []; - TextInputConnection? _textInputConnection; - TextEditingValue? _lastKnownRemoteTextEditingValue; - int _cursorResetLocation = -1; - bool _wasSelectingVerticallyWithKeyboard = false; - EditorTextSelectionOverlay? _selectionOverlay; - FocusAttachment? _focusAttachment; - late CursorCont _cursorCont; - ScrollController? _scrollController; + + // Keyboard + late KeyboardListener _keyboardListener; KeyboardVisibilityController? _keyboardVisibilityController; StreamSubscription? _keyboardVisibilitySubscription; - late KeyboardListener _keyboardListener; - bool _didAutoFocus = false; bool _keyboardVisible = false; - DefaultStyles? _styles; - final ClipboardStatusNotifier? _clipboardStatus = - kIsWeb ? null : ClipboardStatusNotifier(); - final LayerLink _toolbarLayerLink = LayerLink(); - final LayerLink _startHandleLayerLink = LayerLink(); - final LayerLink _endHandleLayerLink = LayerLink(); - - /// Whether to create an input connection with the platform for text editing - /// or not. - /// - /// Read-only input fields do not need a connection with the platform since - /// there's no need for text editing capabilities (e.g. virtual keyboard). - /// - /// On the web, we always need a connection because we want some browser - /// functionalities to continue to work on read-only input fields like: - /// - /// - Relevant context menu. - /// - cmd/ctrl+c shortcut to copy. - /// - cmd/ctrl+a to select all. - /// - Changing the selection using a physical keyboard. - bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly; - - bool get _hasFocus => widget.focusNode.hasFocus; - - TextDirection get _textDirection { - final result = Directionality.of(context); - return result; - } - - void handleCursorMovement( - LogicalKeyboardKey key, - bool wordModifier, - bool lineModifier, - bool shift, - ) { - if (wordModifier && lineModifier) { - return; - } - final selection = widget.controller.selection; - - var newSelection = widget.controller.selection; - - final plainText = textEditingValue.text; - - final rightKey = key == LogicalKeyboardKey.arrowRight, - leftKey = key == LogicalKeyboardKey.arrowLeft, - upKey = key == LogicalKeyboardKey.arrowUp, - downKey = key == LogicalKeyboardKey.arrowDown; - - if ((rightKey || leftKey) && !(rightKey && leftKey)) { - newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier, - leftKey, rightKey, plainText, lineModifier, shift); - } - - if (downKey || upKey) { - newSelection = _handleMovingCursorVertically( - upKey, downKey, shift, selection, newSelection, plainText); - } - - if (!shift) { - newSelection = - _placeCollapsedSelection(selection, newSelection, leftKey, rightKey); - } - - widget.controller.updateSelection(newSelection, ChangeSource.LOCAL); - } - - TextSelection _placeCollapsedSelection(TextSelection selection, - TextSelection newSelection, bool leftKey, bool rightKey) { - var newOffset = newSelection.extentOffset; - if (!selection.isCollapsed) { - if (leftKey) { - newOffset = newSelection.baseOffset < newSelection.extentOffset - ? newSelection.baseOffset - : newSelection.extentOffset; - } else if (rightKey) { - newOffset = newSelection.baseOffset > newSelection.extentOffset - ? newSelection.baseOffset - : newSelection.extentOffset; - } - } - return TextSelection.fromPosition(TextPosition(offset: newOffset)); - } - - TextSelection _handleMovingCursorVertically( - bool upKey, - bool downKey, - bool shift, - TextSelection selection, - TextSelection newSelection, - String plainText) { - final originPosition = TextPosition( - offset: upKey ? selection.baseOffset : selection.extentOffset); - - final child = getRenderEditor()!.childAtPosition(originPosition); - final localPosition = TextPosition( - offset: originPosition.offset - child.getContainer().documentOffset); - - var position = upKey - ? child.getPositionAbove(localPosition) - : child.getPositionBelow(localPosition); - - if (position == null) { - final sibling = upKey - ? getRenderEditor()!.childBefore(child) - : getRenderEditor()!.childAfter(child); - if (sibling == null) { - position = TextPosition(offset: upKey ? 0 : plainText.length - 1); - } else { - final finalOffset = Offset( - child.getOffsetForCaret(localPosition).dx, - sibling - .getOffsetForCaret(TextPosition( - offset: upKey ? sibling.getContainer().length - 1 : 0)) - .dy); - final siblingPosition = sibling.getPositionForOffset(finalOffset); - position = TextPosition( - offset: - sibling.getContainer().documentOffset + siblingPosition.offset); - } - } else { - position = TextPosition( - offset: child.getContainer().documentOffset + position.offset); - } - - if (position.offset == newSelection.extentOffset) { - if (downKey) { - newSelection = newSelection.copyWith(extentOffset: plainText.length); - } else if (upKey) { - newSelection = newSelection.copyWith(extentOffset: 0); - } - _wasSelectingVerticallyWithKeyboard = shift; - return newSelection; - } - - if (_wasSelectingVerticallyWithKeyboard && shift) { - newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation); - _wasSelectingVerticallyWithKeyboard = false; - return newSelection; - } - newSelection = newSelection.copyWith(extentOffset: position.offset); - _cursorResetLocation = newSelection.extentOffset; - return newSelection; - } - - TextSelection _jumpToBeginOrEndOfWord( - TextSelection newSelection, - bool wordModifier, - bool leftKey, - bool rightKey, - String plainText, - bool lineModifier, - bool shift) { - if (wordModifier) { - if (leftKey) { - final textSelection = getRenderEditor()!.selectWordAtPosition( - TextPosition( - offset: _previousCharacter( - newSelection.extentOffset, plainText, false))); - return newSelection.copyWith(extentOffset: textSelection.baseOffset); - } - final textSelection = getRenderEditor()!.selectWordAtPosition( - TextPosition( - offset: - _nextCharacter(newSelection.extentOffset, plainText, false))); - return newSelection.copyWith(extentOffset: textSelection.extentOffset); - } else if (lineModifier) { - if (leftKey) { - final textSelection = getRenderEditor()!.selectLineAtPosition( - TextPosition( - offset: _previousCharacter( - newSelection.extentOffset, plainText, false))); - return newSelection.copyWith(extentOffset: textSelection.baseOffset); - } - final startPoint = newSelection.extentOffset; - if (startPoint < plainText.length) { - final textSelection = getRenderEditor()! - .selectLineAtPosition(TextPosition(offset: startPoint)); - return newSelection.copyWith(extentOffset: textSelection.extentOffset); - } - return newSelection; - } - - if (rightKey && newSelection.extentOffset < plainText.length) { - final nextExtent = - _nextCharacter(newSelection.extentOffset, plainText, true); - final distance = nextExtent - newSelection.extentOffset; - newSelection = newSelection.copyWith(extentOffset: nextExtent); - if (shift) { - _cursorResetLocation += distance; - } - return newSelection; - } - - if (leftKey && newSelection.extentOffset > 0) { - final previousExtent = - _previousCharacter(newSelection.extentOffset, plainText, true); - final distance = newSelection.extentOffset - previousExtent; - newSelection = newSelection.copyWith(extentOffset: previousExtent); - if (shift) { - _cursorResetLocation -= distance; - } - return newSelection; - } - return newSelection; - } - - int _nextCharacter(int index, String string, bool includeWhitespace) { - assert(index >= 0 && index <= string.length); - if (index == string.length) { - return string.length; - } - - var count = 0; - final remain = string.characters.skipWhile((currentString) { - if (count <= index) { - count += currentString.length; - return true; - } - if (includeWhitespace) { - return false; - } - return WHITE_SPACE.contains(currentString.codeUnitAt(0)); - }); - return string.length - remain.toString().length; - } - - int _previousCharacter(int index, String string, includeWhitespace) { - assert(index >= 0 && index <= string.length); - if (index == 0) { - return 0; - } - - var count = 0; - int? lastNonWhitespace; - for (final currentString in string.characters) { - if (!includeWhitespace && - !WHITE_SPACE.contains( - currentString.characters.first.toString().codeUnitAt(0))) { - lastNonWhitespace = count; - } - if (count + currentString.length >= index) { - return includeWhitespace ? count : lastNonWhitespace ?? 0; - } - count += currentString.length; - } - return 0; - } - - bool get hasConnection => - _textInputConnection != null && _textInputConnection!.attached; - - void openConnectionIfNeeded() { - if (!shouldCreateInputConnection) { - return; - } - - if (!hasConnection) { - _lastKnownRemoteTextEditingValue = textEditingValue; - _textInputConnection = TextInput.attach( - this, - TextInputConfiguration( - inputType: TextInputType.multiline, - readOnly: widget.readOnly, - inputAction: TextInputAction.newline, - enableSuggestions: !widget.readOnly, - keyboardAppearance: widget.keyboardAppearance, - textCapitalization: widget.textCapitalization, - ), - ); - - _textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!); - // _sentRemoteValues.add(_lastKnownRemoteTextEditingValue); - } - - _textInputConnection!.show(); - } - - void closeConnectionIfNeeded() { - if (!hasConnection) { - return; - } - _textInputConnection!.close(); - _textInputConnection = null; - _lastKnownRemoteTextEditingValue = null; - _sentRemoteValues.clear(); - } - - void updateRemoteValueIfNeeded() { - if (!hasConnection) { - return; - } - - final actualValue = textEditingValue.copyWith( - composing: _lastKnownRemoteTextEditingValue!.composing, - ); - - if (actualValue == _lastKnownRemoteTextEditingValue) { - return; - } - - final shouldRemember = - textEditingValue.text != _lastKnownRemoteTextEditingValue!.text; - _lastKnownRemoteTextEditingValue = actualValue; - _textInputConnection!.setEditingState(actualValue); - if (shouldRemember) { - _sentRemoteValues.add(actualValue); - } - } + // Selection overlay @override - TextEditingValue? get currentTextEditingValue => - _lastKnownRemoteTextEditingValue; - - @override - AutofillScope? get currentAutofillScope => null; - - @override - void updateEditingValue(TextEditingValue value) { - if (!shouldCreateInputConnection) { - return; - } - - if (_sentRemoteValues.contains(value)) { - _sentRemoteValues.remove(value); - return; - } - - if (_lastKnownRemoteTextEditingValue == value) { - return; - } - - if (_lastKnownRemoteTextEditingValue!.text == value.text && - _lastKnownRemoteTextEditingValue!.selection == value.selection) { - _lastKnownRemoteTextEditingValue = value; - return; - } - - final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!; - _lastKnownRemoteTextEditingValue = value; - final oldText = effectiveLastKnownValue.text; - final text = value.text; - final cursorPosition = value.selection.extentOffset; - final diff = getDiff(oldText, text, cursorPosition); - widget.controller.replaceText( - diff.start, diff.deleted.length, diff.inserted, value.selection); - } - - @override - TextEditingValue get textEditingValue { - return getTextEditingValue(); - } + EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; + EditorTextSelectionOverlay? _selectionOverlay; - @override - set textEditingValue(TextEditingValue value) { - setTextEditingValue(value); - } + ScrollController? _scrollController; - @override - void performAction(TextInputAction action) {} + late CursorCont _cursorCont; - @override - void performPrivateCommand(String action, Map data) {} + // Focus + bool _didAutoFocus = false; + FocusAttachment? _focusAttachment; + bool get _hasFocus => widget.focusNode.hasFocus; - @override - void updateFloatingCursor(RawFloatingCursorPoint point) { - throw UnimplementedError(); - } + DefaultStyles? _styles; @override - void showAutocorrectionPromptRect(int start, int end) { - throw UnimplementedError(); + void bringIntoView(TextPosition position) { + setState(() { + widget.controller.selection = TextSelection( + baseOffset: widget.controller.selection.baseOffset, + extentOffset: position.offset); + }); } + final ClipboardStatusNotifier? _clipboardStatus = + kIsWeb ? null : ClipboardStatusNotifier(); + final LayerLink _toolbarLayerLink = LayerLink(); + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); @override void bringIntoView(TextPosition position) { setState(() { @@ -498,16 +149,7 @@ class RawEditorState extends EditorState }); } - @override - void connectionClosed() { - if (!hasConnection) { - return; - } - _textInputConnection!.connectionClosedReceived(); - _textInputConnection = null; - _lastKnownRemoteTextEditingValue = null; - _sentRemoteValues.clear(); - } + TextDirection get _textDirection => Directionality.of(context); @override Widget build(BuildContext context) { @@ -585,6 +227,18 @@ class RawEditorState extends EditorState } } + /// Updates the checkbox positioned at [offset] in document + /// by changing its attribute according to [value]. + void _handleCheckboxTap(int offset, bool value) { + if (!widget.readOnly) { + if (value) { + widget.controller.formatText(offset, 0, Attribute.checked); + } else { + widget.controller.formatText(offset, 0, Attribute.unchecked); + } + } + } + List _buildChildren(Document doc, BuildContext context) { final result = []; final indentLevelCounts = {}; @@ -595,21 +249,23 @@ class RawEditorState extends EditorState } else if (node is Block) { final attrs = node.style.attributes; final editableTextBlock = EditableTextBlock( - node, - _textDirection, - widget.scrollBottomInset, - _getVerticalSpacingForBlock(node, _styles), - widget.controller.selection, - widget.selectionColor, - _styles, - widget.enableInteractiveSelection, - _hasFocus, - attrs.containsKey(Attribute.codeBlock.key) - ? const EdgeInsets.all(16) - : null, - widget.embedBuilder, - _cursorCont, - indentLevelCounts); + node, + _textDirection, + widget.scrollBottomInset, + _getVerticalSpacingForBlock(node, _styles), + widget.controller.selection, + widget.selectionColor, + _styles, + widget.enableInteractiveSelection, + _hasFocus, + attrs.containsKey(Attribute.codeBlock.key) + ? const EdgeInsets.all(16) + : null, + widget.embedBuilder, + _cursorCont, + indentLevelCounts, + _handleCheckboxTap, + ); result.add(editableTextBlock); } else { throw StateError('Unreachable.'); @@ -788,89 +444,6 @@ class RawEditorState extends EditorState !widget.controller.selection.isCollapsed; } - void handleDelete(bool forward) { - final selection = widget.controller.selection; - final plainText = textEditingValue.text; - var cursorPosition = selection.start; - var textBefore = selection.textBefore(plainText); - var textAfter = selection.textAfter(plainText); - if (selection.isCollapsed) { - if (!forward && textBefore.isNotEmpty) { - final characterBoundary = - _previousCharacter(textBefore.length, textBefore, true); - textBefore = textBefore.substring(0, characterBoundary); - cursorPosition = characterBoundary; - } - if (forward && textAfter.isNotEmpty && textAfter != '\n') { - final deleteCount = _nextCharacter(0, textAfter, true); - textAfter = textAfter.substring(deleteCount); - } - } - final newSelection = TextSelection.collapsed(offset: cursorPosition); - final newText = textBefore + textAfter; - final size = plainText.length - newText.length; - widget.controller.replaceText( - cursorPosition, - size, - '', - newSelection, - ); - } - - Future handleShortcut(InputShortcut? shortcut) async { - final selection = widget.controller.selection; - final plainText = textEditingValue.text; - if (shortcut == InputShortcut.COPY) { - if (!selection.isCollapsed) { - await Clipboard.setData( - ClipboardData(text: selection.textInside(plainText))); - } - return; - } - if (shortcut == InputShortcut.CUT && !widget.readOnly) { - if (!selection.isCollapsed) { - final data = selection.textInside(plainText); - await Clipboard.setData(ClipboardData(text: data)); - - widget.controller.replaceText( - selection.start, - data.length, - '', - TextSelection.collapsed(offset: selection.start), - ); - - textEditingValue = TextEditingValue( - text: - selection.textBefore(plainText) + selection.textAfter(plainText), - selection: TextSelection.collapsed(offset: selection.start), - ); - } - return; - } - if (shortcut == InputShortcut.PASTE && !widget.readOnly) { - final data = await Clipboard.getData(Clipboard.kTextPlain); - if (data != null) { - widget.controller.replaceText( - selection.start, - selection.end - selection.start, - data.text, - TextSelection.collapsed(offset: selection.start + data.text!.length), - ); - } - return; - } - if (shortcut == InputShortcut.SELECT_ALL && - widget.enableInteractiveSelection) { - widget.controller.updateSelection( - selection.copyWith( - baseOffset: 0, - extentOffset: textEditingValue.text.length, - ), - ChangeSource.REMOTE); - return; - } - } - @override void dispose() { closeConnectionIfNeeded(); @@ -1031,11 +604,6 @@ class RawEditorState extends EditorState return _editorKey.currentContext!.findRenderObject() as RenderEditor?; } - @override - EditorTextSelectionOverlay? getSelectionOverlay() { - return _selectionOverlay; - } - @override TextEditingValue getTextEditingValue() { return widget.controller.plainTextEditingValue; @@ -1137,12 +705,10 @@ class RawEditorState extends EditorState @override bool get wantKeepAlive => widget.focusNode.hasFocus; - void openOrCloseConnection() { - if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) { - openConnectionIfNeeded(); - } else if (!widget.focusNode.hasFocus) { - closeConnectionIfNeeded(); - } + @override + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause cause) { + // TODO: implement userUpdateTextEditingValue } @override diff --git a/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart b/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart new file mode 100644 index 00000000..0eb7f955 --- /dev/null +++ b/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart @@ -0,0 +1,354 @@ +import 'dart:ui'; + +import 'package:characters/characters.dart'; +import 'package:flutter/services.dart'; + +import '../../models/documents/document.dart'; +import '../../utils/diff_delta.dart'; +import '../editor.dart'; +import '../keyboard_listener.dart'; + +mixin RawEditorStateKeyboardMixin on EditorState { + // Holds the last cursor location the user selected in the case the user tries + // to select vertically past the end or beginning of the field. If they do, + // then we need to keep the old cursor location so that we can go back to it + // if they change their minds. Only used for moving selection up and down in a + // multiline text field when selecting using the keyboard. + int _cursorResetLocation = -1; + + // Whether we should reset the location of the cursor in the case the user + // tries to select vertically past the end or beginning of the field. If they + // do, then we need to keep the old cursor location so that we can go back to + // it if they change their minds. Only used for resetting selection up and + // down in a multiline text field when selecting using the keyboard. + bool _wasSelectingVerticallyWithKeyboard = false; + + void handleCursorMovement( + LogicalKeyboardKey key, + bool wordModifier, + bool lineModifier, + bool shift, + ) { + if (wordModifier && lineModifier) { + // If both modifiers are down, nothing happens on any of the platforms. + return; + } + final selection = widget.controller.selection; + + var newSelection = widget.controller.selection; + + final plainText = getTextEditingValue().text; + + final rightKey = key == LogicalKeyboardKey.arrowRight, + leftKey = key == LogicalKeyboardKey.arrowLeft, + upKey = key == LogicalKeyboardKey.arrowUp, + downKey = key == LogicalKeyboardKey.arrowDown; + + if ((rightKey || leftKey) && !(rightKey && leftKey)) { + newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier, + leftKey, rightKey, plainText, lineModifier, shift); + } + + if (downKey || upKey) { + newSelection = _handleMovingCursorVertically( + upKey, downKey, shift, selection, newSelection, plainText); + } + + if (!shift) { + newSelection = + _placeCollapsedSelection(selection, newSelection, leftKey, rightKey); + } + + widget.controller.updateSelection(newSelection, ChangeSource.LOCAL); + } + + // Handles shortcut functionality including cut, copy, paste and select all + // using control/command + (X, C, V, A). + // TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic) + Future handleShortcut(InputShortcut? shortcut) async { + final selection = widget.controller.selection; + final plainText = getTextEditingValue().text; + if (shortcut == InputShortcut.COPY) { + if (!selection.isCollapsed) { + await Clipboard.setData( + ClipboardData(text: selection.textInside(plainText))); + } + return; + } + if (shortcut == InputShortcut.CUT && !widget.readOnly) { + if (!selection.isCollapsed) { + final data = selection.textInside(plainText); + await Clipboard.setData(ClipboardData(text: data)); + + widget.controller.replaceText( + selection.start, + data.length, + '', + TextSelection.collapsed(offset: selection.start), + ); + + setTextEditingValue(TextEditingValue( + text: + selection.textBefore(plainText) + selection.textAfter(plainText), + selection: TextSelection.collapsed(offset: selection.start), + )); + } + return; + } + if (shortcut == InputShortcut.PASTE && !widget.readOnly) { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null) { + widget.controller.replaceText( + selection.start, + selection.end - selection.start, + data.text, + TextSelection.collapsed(offset: selection.start + data.text!.length), + ); + } + return; + } + if (shortcut == InputShortcut.SELECT_ALL && + widget.enableInteractiveSelection) { + widget.controller.updateSelection( + selection.copyWith( + baseOffset: 0, + extentOffset: getTextEditingValue().text.length, + ), + ChangeSource.REMOTE); + return; + } + } + + void handleDelete(bool forward) { + final selection = widget.controller.selection; + final plainText = getTextEditingValue().text; + var cursorPosition = selection.start; + var textBefore = selection.textBefore(plainText); + var textAfter = selection.textAfter(plainText); + if (selection.isCollapsed) { + if (!forward && textBefore.isNotEmpty) { + final characterBoundary = + _previousCharacter(textBefore.length, textBefore, true); + textBefore = textBefore.substring(0, characterBoundary); + cursorPosition = characterBoundary; + } + if (forward && textAfter.isNotEmpty && textAfter != '\n') { + final deleteCount = _nextCharacter(0, textAfter, true); + textAfter = textAfter.substring(deleteCount); + } + } + final newSelection = TextSelection.collapsed(offset: cursorPosition); + final newText = textBefore + textAfter; + final size = plainText.length - newText.length; + widget.controller.replaceText( + cursorPosition, + size, + '', + newSelection, + ); + } + + TextSelection _jumpToBeginOrEndOfWord( + TextSelection newSelection, + bool wordModifier, + bool leftKey, + bool rightKey, + String plainText, + bool lineModifier, + bool shift) { + if (wordModifier) { + if (leftKey) { + final textSelection = getRenderEditor()!.selectWordAtPosition( + TextPosition( + offset: _previousCharacter( + newSelection.extentOffset, plainText, false))); + return newSelection.copyWith(extentOffset: textSelection.baseOffset); + } + final textSelection = getRenderEditor()!.selectWordAtPosition( + TextPosition( + offset: + _nextCharacter(newSelection.extentOffset, plainText, false))); + return newSelection.copyWith(extentOffset: textSelection.extentOffset); + } else if (lineModifier) { + if (leftKey) { + final textSelection = getRenderEditor()!.selectLineAtPosition( + TextPosition( + offset: _previousCharacter( + newSelection.extentOffset, plainText, false))); + return newSelection.copyWith(extentOffset: textSelection.baseOffset); + } + final startPoint = newSelection.extentOffset; + if (startPoint < plainText.length) { + final textSelection = getRenderEditor()! + .selectLineAtPosition(TextPosition(offset: startPoint)); + return newSelection.copyWith(extentOffset: textSelection.extentOffset); + } + return newSelection; + } + + if (rightKey && newSelection.extentOffset < plainText.length) { + final nextExtent = + _nextCharacter(newSelection.extentOffset, plainText, true); + final distance = nextExtent - newSelection.extentOffset; + newSelection = newSelection.copyWith(extentOffset: nextExtent); + if (shift) { + _cursorResetLocation += distance; + } + return newSelection; + } + + if (leftKey && newSelection.extentOffset > 0) { + final previousExtent = + _previousCharacter(newSelection.extentOffset, plainText, true); + final distance = newSelection.extentOffset - previousExtent; + newSelection = newSelection.copyWith(extentOffset: previousExtent); + if (shift) { + _cursorResetLocation -= distance; + } + return newSelection; + } + return newSelection; + } + + /// Returns the index into the string of the next character boundary after the + /// given index. + /// + /// The character boundary is determined by the characters package, so + /// surrogate pairs and extended grapheme clusters are considered. + /// + /// The index must be between 0 and string.length, inclusive. If given + /// string.length, string.length is returned. + /// + /// Setting includeWhitespace to false will only return the index of non-space + /// characters. + int _nextCharacter(int index, String string, bool includeWhitespace) { + assert(index >= 0 && index <= string.length); + if (index == string.length) { + return string.length; + } + + var count = 0; + final remain = string.characters.skipWhile((currentString) { + if (count <= index) { + count += currentString.length; + return true; + } + if (includeWhitespace) { + return false; + } + return WHITE_SPACE.contains(currentString.codeUnitAt(0)); + }); + return string.length - remain.toString().length; + } + + /// Returns the index into the string of the previous character boundary + /// before the given index. + /// + /// The character boundary is determined by the characters package, so + /// surrogate pairs and extended grapheme clusters are considered. + /// + /// The index must be between 0 and string.length, inclusive. If index is 0, + /// 0 will be returned. + /// + /// Setting includeWhitespace to false will only return the index of non-space + /// characters. + int _previousCharacter(int index, String string, includeWhitespace) { + assert(index >= 0 && index <= string.length); + if (index == 0) { + return 0; + } + + var count = 0; + int? lastNonWhitespace; + for (final currentString in string.characters) { + if (!includeWhitespace && + !WHITE_SPACE.contains( + currentString.characters.first.toString().codeUnitAt(0))) { + lastNonWhitespace = count; + } + if (count + currentString.length >= index) { + return includeWhitespace ? count : lastNonWhitespace ?? 0; + } + count += currentString.length; + } + return 0; + } + + TextSelection _handleMovingCursorVertically( + bool upKey, + bool downKey, + bool shift, + TextSelection selection, + TextSelection newSelection, + String plainText) { + final originPosition = TextPosition( + offset: upKey ? selection.baseOffset : selection.extentOffset); + + final child = getRenderEditor()!.childAtPosition(originPosition); + final localPosition = TextPosition( + offset: originPosition.offset - child.getContainer().documentOffset); + + var position = upKey + ? child.getPositionAbove(localPosition) + : child.getPositionBelow(localPosition); + + if (position == null) { + final sibling = upKey + ? getRenderEditor()!.childBefore(child) + : getRenderEditor()!.childAfter(child); + if (sibling == null) { + position = TextPosition(offset: upKey ? 0 : plainText.length - 1); + } else { + final finalOffset = Offset( + child.getOffsetForCaret(localPosition).dx, + sibling + .getOffsetForCaret(TextPosition( + offset: upKey ? sibling.getContainer().length - 1 : 0)) + .dy); + final siblingPosition = sibling.getPositionForOffset(finalOffset); + position = TextPosition( + offset: + sibling.getContainer().documentOffset + siblingPosition.offset); + } + } else { + position = TextPosition( + offset: child.getContainer().documentOffset + position.offset); + } + + if (position.offset == newSelection.extentOffset) { + if (downKey) { + newSelection = newSelection.copyWith(extentOffset: plainText.length); + } else if (upKey) { + newSelection = newSelection.copyWith(extentOffset: 0); + } + _wasSelectingVerticallyWithKeyboard = shift; + return newSelection; + } + + if (_wasSelectingVerticallyWithKeyboard && shift) { + newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation); + _wasSelectingVerticallyWithKeyboard = false; + return newSelection; + } + newSelection = newSelection.copyWith(extentOffset: position.offset); + _cursorResetLocation = newSelection.extentOffset; + return newSelection; + } + + TextSelection _placeCollapsedSelection(TextSelection selection, + TextSelection newSelection, bool leftKey, bool rightKey) { + var newOffset = newSelection.extentOffset; + if (!selection.isCollapsed) { + if (leftKey) { + newOffset = newSelection.baseOffset < newSelection.extentOffset + ? newSelection.baseOffset + : newSelection.extentOffset; + } else if (rightKey) { + newOffset = newSelection.baseOffset > newSelection.extentOffset + ? newSelection.baseOffset + : newSelection.extentOffset; + } + } + return TextSelection.fromPosition(TextPosition(offset: newOffset)); + } +} diff --git a/lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart new file mode 100644 index 00000000..cda991cc --- /dev/null +++ b/lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; + +import '../editor.dart'; + +mixin RawEditorStateSelectionDelegateMixin on EditorState + implements TextSelectionDelegate { + @override + TextEditingValue get textEditingValue { + return getTextEditingValue(); + } + + @override + set textEditingValue(TextEditingValue value) { + setTextEditingValue(value); + } + + @override + void bringIntoView(TextPosition position) { + // TODO: implement bringIntoView + } + + @override + void hideToolbar([bool hideHandles = true]) { + if (getSelectionOverlay()?.toolbar != null) { + getSelectionOverlay()?.hideToolbar(); + } + } + + @override + bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; + + @override + bool get copyEnabled => widget.toolbarOptions.copy; + + @override + bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; + + @override + bool get selectAllEnabled => widget.toolbarOptions.selectAll; +} diff --git a/lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart new file mode 100644 index 00000000..527df582 --- /dev/null +++ b/lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart @@ -0,0 +1,200 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../../utils/diff_delta.dart'; +import '../editor.dart'; + +mixin RawEditorStateTextInputClientMixin on EditorState + implements TextInputClient { + final List _sentRemoteValues = []; + TextInputConnection? _textInputConnection; + TextEditingValue? _lastKnownRemoteTextEditingValue; + + /// Whether to create an input connection with the platform for text editing + /// or not. + /// + /// Read-only input fields do not need a connection with the platform since + /// there's no need for text editing capabilities (e.g. virtual keyboard). + /// + /// On the web, we always need a connection because we want some browser + /// functionalities to continue to work on read-only input fields like: + /// + /// - Relevant context menu. + /// - cmd/ctrl+c shortcut to copy. + /// - cmd/ctrl+a to select all. + /// - Changing the selection using a physical keyboard. + bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly; + + /// Returns `true` if there is open input connection. + bool get hasConnection => + _textInputConnection != null && _textInputConnection!.attached; + + /// Opens or closes input connection based on the current state of + /// [focusNode] and [value]. + void openOrCloseConnection() { + if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) { + openConnectionIfNeeded(); + } else if (!widget.focusNode.hasFocus) { + closeConnectionIfNeeded(); + } + } + + void openConnectionIfNeeded() { + if (!shouldCreateInputConnection) { + return; + } + + if (!hasConnection) { + _lastKnownRemoteTextEditingValue = getTextEditingValue(); + _textInputConnection = TextInput.attach( + this, + TextInputConfiguration( + inputType: TextInputType.multiline, + readOnly: widget.readOnly, + inputAction: TextInputAction.newline, + enableSuggestions: !widget.readOnly, + keyboardAppearance: widget.keyboardAppearance, + textCapitalization: widget.textCapitalization, + ), + ); + + _textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!); + // _sentRemoteValues.add(_lastKnownRemoteTextEditingValue); + } + + _textInputConnection!.show(); + } + + /// Closes input connection if it's currently open. Otherwise does nothing. + void closeConnectionIfNeeded() { + if (!hasConnection) { + return; + } + _textInputConnection!.close(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + _sentRemoteValues.clear(); + } + + /// Updates remote value based on current state of [document] and + /// [selection]. + /// + /// This method may not actually send an update to native side if it thinks + /// remote value is up to date or identical. + void updateRemoteValueIfNeeded() { + if (!hasConnection) { + return; + } + + // Since we don't keep track of the composing range in value provided + // by the Controller we need to add it here manually before comparing + // with the last known remote value. + // It is important to prevent excessive remote updates as it can cause + // race conditions. + final actualValue = getTextEditingValue().copyWith( + composing: _lastKnownRemoteTextEditingValue!.composing, + ); + + if (actualValue == _lastKnownRemoteTextEditingValue) { + return; + } + + final shouldRemember = + getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text; + _lastKnownRemoteTextEditingValue = actualValue; + _textInputConnection!.setEditingState(actualValue); + if (shouldRemember) { + // Only keep track if text changed (selection changes are not relevant) + _sentRemoteValues.add(actualValue); + } + } + + @override + TextEditingValue? get currentTextEditingValue => + _lastKnownRemoteTextEditingValue; + + // autofill is not needed + @override + AutofillScope? get currentAutofillScope => null; + + @override + void updateEditingValue(TextEditingValue value) { + if (!shouldCreateInputConnection) { + return; + } + + if (_sentRemoteValues.contains(value)) { + /// There is a race condition in Flutter text input plugin where sending + /// updates to native side too often results in broken behavior. + /// TextInputConnection.setEditingValue is an async call to native side. + /// For each such call native side _always_ sends an update which triggers + /// this method (updateEditingValue) with the same value we've sent it. + /// If multiple calls to setEditingValue happen too fast and we only + /// track the last sent value then there is no way for us to filter out + /// automatic callbacks from native side. + /// Therefore we have to keep track of all values we send to the native + /// side and when we see this same value appear here we skip it. + /// This is fragile but it's probably the only available option. + _sentRemoteValues.remove(value); + return; + } + + if (_lastKnownRemoteTextEditingValue == value) { + // There is no difference between this value and the last known value. + return; + } + + // Check if only composing range changed. + if (_lastKnownRemoteTextEditingValue!.text == value.text && + _lastKnownRemoteTextEditingValue!.selection == value.selection) { + // This update only modifies composing range. Since we don't keep track + // of composing range we just need to update last known value here. + // This check fixes an issue on Android when it sends + // composing updates separately from regular changes for text and + // selection. + _lastKnownRemoteTextEditingValue = value; + return; + } + + final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!; + _lastKnownRemoteTextEditingValue = value; + final oldText = effectiveLastKnownValue.text; + final text = value.text; + final cursorPosition = value.selection.extentOffset; + final diff = getDiff(oldText, text, cursorPosition); + widget.controller.replaceText( + diff.start, diff.deleted.length, diff.inserted, value.selection); + } + + @override + void performAction(TextInputAction action) { + // no-op + } + + @override + void performPrivateCommand(String action, Map data) { + // no-op + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + throw UnimplementedError(); + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + throw UnimplementedError(); + } + + @override + void connectionClosed() { + if (!hasConnection) { + return; + } + _textInputConnection!.connectionClosedReceived(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + _sentRemoteValues.clear(); + } +} diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart new file mode 100644 index 00000000..ee1a7732 --- /dev/null +++ b/lib/widgets/simple_viewer.dart @@ -0,0 +1,344 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:tuple/tuple.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/document.dart'; +import '../models/documents/nodes/block.dart'; +import '../models/documents/nodes/leaf.dart' as leaf; +import '../models/documents/nodes/line.dart'; +import 'controller.dart'; +import 'cursor.dart'; +import 'default_styles.dart'; +import 'delegate.dart'; +import 'editor.dart'; +import 'text_block.dart'; +import 'text_line.dart'; + +class QuillSimpleViewer extends StatefulWidget { + const QuillSimpleViewer({ + required this.controller, + this.customStyles, + this.truncate = false, + this.truncateScale, + this.truncateAlignment, + this.truncateHeight, + this.truncateWidth, + this.scrollBottomInset = 0, + this.padding = EdgeInsets.zero, + this.embedBuilder, + Key? key, + }) : assert(truncate || + ((truncateScale == null) && + (truncateAlignment == null) && + (truncateHeight == null) && + (truncateWidth == null))), + super(key: key); + + final QuillController controller; + final DefaultStyles? customStyles; + final bool truncate; + final double? truncateScale; + final Alignment? truncateAlignment; + final double? truncateHeight; + final double? truncateWidth; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + final EmbedBuilder? embedBuilder; + + @override + _QuillSimpleViewerState createState() => _QuillSimpleViewerState(); +} + +class _QuillSimpleViewerState extends State + with SingleTickerProviderStateMixin { + late DefaultStyles _styles; + final LayerLink _toolbarLayerLink = LayerLink(); + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + late CursorCont _cursorCont; + + @override + void initState() { + super.initState(); + + _cursorCont = CursorCont( + show: ValueNotifier(false), + style: const CursorStyle( + color: Colors.black, + backgroundColor: Colors.grey, + width: 2, + radius: Radius.zero, + offset: Offset.zero, + ), + tickerProvider: this, + ); + } + + @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!); + } + } + + EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder; + + Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) { + assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); + switch (node.value.type) { + case 'image': + final imageUrl = _standardizeImageUrl(node.value.data); + return imageUrl.startsWith('http') + ? Image.network(imageUrl) + : isBase64(imageUrl) + ? Image.memory(base64.decode(imageUrl)) + : Image.file(io.File(imageUrl)); + default: + throw UnimplementedError( + 'Embeddable type "${node.value.type}" is not supported by default embed ' + 'builder of QuillEditor. You must pass your own builder function to ' + 'embedBuilder property of QuillEditor or QuillField widgets.'); + } + } + + String _standardizeImageUrl(String url) { + if (url.contains('base64')) { + return url.split(',')[1]; + } + return url; + } + + @override + Widget build(BuildContext context) { + final _doc = widget.controller.document; + // if (_doc.isEmpty() && + // !widget.focusNode.hasFocus && + // widget.placeholder != null) { + // _doc = Document.fromJson(jsonDecode( + // '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); + // } + + Widget child = CompositedTransformTarget( + link: _toolbarLayerLink, + child: Semantics( + child: _SimpleViewer( + document: _doc, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _nullSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + children: _buildChildren(_doc, context), + ), + ), + ); + + if (widget.truncate) { + if (widget.truncateScale != null) { + child = Container( + height: widget.truncateHeight, + child: Align( + heightFactor: widget.truncateScale, + widthFactor: widget.truncateScale, + alignment: widget.truncateAlignment ?? Alignment.topLeft, + child: Container( + width: widget.truncateWidth! / widget.truncateScale!, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Transform.scale( + scale: widget.truncateScale!, + alignment: + widget.truncateAlignment ?? Alignment.topLeft, + child: child))))); + } else { + child = Container( + height: widget.truncateHeight, + width: widget.truncateWidth, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), child: child)); + } + } + + return QuillStyles(data: _styles, child: child); + } + + List _buildChildren(Document doc, BuildContext context) { + final result = []; + final indentLevelCounts = {}; + for (final node in doc.root.children) { + if (node is Line) { + final editableTextLine = _getEditableTextLineFromNode(node, context); + result.add(editableTextLine); + } else if (node is Block) { + final attrs = node.style.attributes; + final editableTextBlock = EditableTextBlock( + node, + _textDirection, + widget.scrollBottomInset, + _getVerticalSpacingForBlock(node, _styles), + widget.controller.selection, + Colors.black, + // selectionColor, + _styles, + false, + // enableInteractiveSelection, + false, + // hasFocus, + attrs.containsKey(Attribute.codeBlock.key) + ? const EdgeInsets.all(16) + : null, + embedBuilder, + _cursorCont, + indentLevelCounts, + _handleCheckboxTap); + result.add(editableTextBlock); + } else { + throw StateError('Unreachable.'); + } + } + return result; + } + + /// Updates the checkbox positioned at [offset] in document + /// by changing its attribute according to [value]. + void _handleCheckboxTap(int offset, bool value) { + // readonly - do nothing + } + + TextDirection get _textDirection { + final result = Directionality.of(context); + return result; + } + + EditableTextLine _getEditableTextLineFromNode( + Line node, BuildContext context) { + final textLine = TextLine( + line: node, + textDirection: _textDirection, + embedBuilder: embedBuilder, + styles: _styles, + ); + final editableTextLine = EditableTextLine( + node, + null, + textLine, + 0, + _getVerticalSpacingForLine(node, _styles), + _textDirection, + widget.controller.selection, + Colors.black, + //widget.selectionColor, + false, + //enableInteractiveSelection, + false, + //_hasFocus, + MediaQuery.of(context).devicePixelRatio, + _cursorCont); + return editableTextLine; + } + + Tuple2 _getVerticalSpacingForLine( + Line line, DefaultStyles? defaultStyles) { + final attrs = line.style.attributes; + if (attrs.containsKey(Attribute.header.key)) { + final int? 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; + } + + Tuple2 _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; + } + return defaultStyles!.lists!.verticalSpacing; + } + + void _nullSelectionChanged( + TextSelection selection, SelectionChangedCause cause) {} +} + +class _SimpleViewer extends MultiChildRenderObjectWidget { + _SimpleViewer({ + required List children, + required this.document, + required this.textDirection, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.onSelectionChanged, + required this.scrollBottomInset, + this.padding = EdgeInsets.zero, + Key? key, + }) : super(key: key, children: children); + + final Document document; + final TextDirection textDirection; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final TextSelectionChangedHandler onSelectionChanged; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + + @override + RenderEditor createRenderObject(BuildContext context) { + return RenderEditor( + null, + textDirection, + scrollBottomInset, + padding, + document, + const TextSelection(baseOffset: 0, extentOffset: 0), + false, + // hasFocus, + onSelectionChanged, + startHandleLayerLink, + endHandleLayerLink, + const EdgeInsets.fromLTRB(4, 4, 4, 5), + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderEditor renderObject) { + renderObject + ..document = document + ..setContainer(document.root) + ..textDirection = textDirection + ..setStartHandleLayerLink(startHandleLayerLink) + ..setEndHandleLayerLink(endHandleLayerLink) + ..onSelectionChanged = onSelectionChanged + ..setScrollBottomInset(scrollBottomInset) + ..setPadding(padding); + } +} diff --git a/lib/widgets/text_block.dart b/lib/widgets/text_block.dart index 309b9cf8..f533a160 100644 --- a/lib/widgets/text_block.dart +++ b/lib/widgets/text_block.dart @@ -61,6 +61,7 @@ class EditableTextBlock extends StatelessWidget { this.embedBuilder, this.cursorCont, this.indentLevelCounts, + this.onCheckboxTap, ); final Block block; @@ -76,6 +77,7 @@ class EditableTextBlock extends StatelessWidget { final EmbedBuilder embedBuilder; final CursorCont cursorCont; final Map indentLevelCounts; + final Function(int, bool) onCheckboxTap; @override Widget build(BuildContext context) { @@ -161,12 +163,23 @@ class EditableTextBlock extends StatelessWidget { if (attrs[Attribute.list.key] == Attribute.checked) { return _Checkbox( - style: defaultStyles!.leading!.style, width: 32, isChecked: true); + key: UniqueKey(), + style: defaultStyles!.leading!.style, + width: 32, + isChecked: true, + offset: block.offset + line.offset, + onTap: onCheckboxTap, + ); } if (attrs[Attribute.list.key] == Attribute.unchecked) { return _Checkbox( - style: defaultStyles!.leading!.style, width: 32, isChecked: false); + key: UniqueKey(), + style: defaultStyles!.leading!.style, + width: 32, + offset: block.offset + line.offset, + onTap: onCheckboxTap, + ); } if (attrs.containsKey(Attribute.codeBlock.key)) { @@ -685,46 +698,39 @@ class _BulletPoint extends StatelessWidget { } } -class _Checkbox extends StatefulWidget { - const _Checkbox({Key? key, this.style, this.width, this.isChecked}) - : super(key: key); - +class _Checkbox extends StatelessWidget { + const _Checkbox({ + Key? key, + this.style, + this.width, + this.isChecked = false, + this.offset, + this.onTap, + }) : super(key: key); final TextStyle? style; final double? width; - final bool? isChecked; + final bool isChecked; + final int? offset; + final Function(int, bool)? onTap; - @override - __CheckboxState createState() => __CheckboxState(); -} - -class __CheckboxState extends State<_Checkbox> { - bool? isChecked; - - void _onCheckboxClicked(bool? newValue) => setState(() { - isChecked = newValue; - - if (isChecked!) { - // check list - } else { - // uncheck list - } - }); - - @override - void initState() { - super.initState(); - isChecked = widget.isChecked; + void _onCheckboxClicked(bool? newValue) { + if (onTap != null && newValue != null && offset != null) { + onTap!(offset!, newValue); + } } @override Widget build(BuildContext context) { return Container( alignment: AlignmentDirectional.topEnd, - width: widget.width, + width: width, padding: const EdgeInsetsDirectional.only(end: 13), - child: Checkbox( - value: widget.isChecked, - onChanged: _onCheckboxClicked, + child: GestureDetector( + onLongPress: () => _onCheckboxClicked(!isChecked), + child: Checkbox( + value: isChecked, + onChanged: _onCheckboxClicked, + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 5e87f6d7..7cf52633 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_quill description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 1.2.1 +version: 1.3.0 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill