From f5a1a61938958c28460332f968935a8dc6d805ff Mon Sep 17 00:00:00 2001 From: singerdmx Date: Thu, 17 Dec 2020 22:20:35 -0800 Subject: [PATCH] Add delegate and controller --- lib/models/documents/document.dart | 21 +++ lib/models/documents/nodes/line.dart | 46 +++++ lib/models/documents/style.dart | 12 ++ lib/widgets/controller.dart | 124 ++++++++++++++ lib/widgets/delegate.dart | 31 ++++ lib/widgets/editor.dart | 247 +++++++++++++++++++++++++++ lib/widgets/keyborad_listener.dart | 100 +++++++++++ lib/widgets/text_selection.dart | 83 +++++++++ 8 files changed, 664 insertions(+) create mode 100644 lib/widgets/controller.dart create mode 100644 lib/widgets/delegate.dart create mode 100644 lib/widgets/editor.dart create mode 100644 lib/widgets/keyborad_listener.dart create mode 100644 lib/widgets/text_selection.dart diff --git a/lib/models/documents/document.dart b/lib/models/documents/document.dart index c6652951..cc81b02e 100644 --- a/lib/models/documents/document.dart +++ b/lib/models/documents/document.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:flutter_quill/models/documents/nodes/block.dart'; +import 'package:flutter_quill/models/documents/nodes/container.dart'; +import 'package:flutter_quill/models/documents/nodes/line.dart'; import 'package:flutter_quill/models/documents/style.dart'; import 'package:quill_delta/quill_delta.dart'; import 'package:tuple/tuple.dart'; @@ -87,6 +90,20 @@ class Document { return delta; } + Style collectStyle(int index, int len) { + ChildQuery res = queryChild(index); + return (res.node as Line).collectStyle(res.offset, len); + } + + ChildQuery queryChild(int offset) { + ChildQuery res = _root.queryChild(offset, true); + if (res.node is Line) { + return res; + } + Block block = res.node; + return block.queryChild(res.offset, true); + } + compose(Delta delta, ChangeSource changeSource) { assert(!_observer.isClosed); delta.trim(); @@ -135,6 +152,10 @@ class Document { ? data : Embeddable.fromJson(data); } + + close() { + _observer.close(); + } } enum ChangeSource { diff --git a/lib/models/documents/nodes/line.dart b/lib/models/documents/nodes/line.dart index 0f7c7848..6323f2ec 100644 --- a/lib/models/documents/nodes/line.dart +++ b/lib/models/documents/nodes/line.dart @@ -261,4 +261,50 @@ class Line extends Container { Node newInstance() { return Line(); } + + Style collectStyle(int offset, int len) { + int local = math.min(this.length - offset, len); + Style res = Style(); + var excluded = {}; + + void _handle(Style style) { + if (res.isEmpty) { + excluded.addAll(style.values); + } else { + for (Attribute attr in res.values) { + if (!style.containsKey(attr.key)) { + excluded.add(attr); + } + } + } + Style remain = style.removeAll(excluded); + res = res.removeAll(excluded); + res = res.mergeAll(remain); + } + + ChildQuery data = queryChild(offset, true); + Leaf node = data.node; + if (node != null) { + res = res.mergeAll(node.style); + int pos = node.length - data.offset; + while (!node.isLast && pos < local) { + node = node.next as Leaf; + _handle(node.style); + pos += node.length; + } + } + + res = res.mergeAll(style); + if (parent is Block) { + Block block = parent; + res = res.mergeAll(block.style); + } + + int remain = len - local; + if (remain > 0) { + _handle(nextLine.collectStyle(0, remain)); + } + + return res; + } } diff --git a/lib/models/documents/style.dart b/lib/models/documents/style.dart index b9855107..67194755 100644 --- a/lib/models/documents/style.dart +++ b/lib/models/documents/style.dart @@ -67,4 +67,16 @@ class Style { } return result; } + + Style removeAll(Set attributes) { + Map merged = Map.from(_attributes); + attributes.map((item) => item.key).forEach(merged.remove); + return Style.attr(merged); + } + + Style put(Attribute attribute) { + Map m = Map.from(attributes); + m[attribute.key] = attribute; + return Style.attr(m); + } } diff --git a/lib/widgets/controller.dart b/lib/widgets/controller.dart new file mode 100644 index 00000000..4628a982 --- /dev/null +++ b/lib/widgets/controller.dart @@ -0,0 +1,124 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_quill/models/documents/attribute.dart'; +import 'package:flutter_quill/models/documents/document.dart'; +import 'package:flutter_quill/models/documents/nodes/embed.dart'; +import 'package:flutter_quill/models/documents/style.dart'; +import 'package:flutter_quill/utils/diff_delta.dart'; +import 'package:quill_delta/quill_delta.dart'; + +class QuillController extends ChangeNotifier { + final Document document; + TextSelection selection; + Style toggledStyle = Style(); + + QuillController(this.document, this.selection, this.toggledStyle) + : assert(document != null), + assert(selection != null); + + Style getSelectionStyle() { + return document + .collectStyle(selection.start, selection.end - selection.start) + .mergeAll(toggledStyle); + } + + replaceText(int index, int len, Object data, TextSelection textSelection) { + assert(data is String || data is Embeddable); + + Delta delta; + if (len > 0 || data is! String || (data as String).isNotEmpty) { + delta = document.replace(index, len, data); + if (delta != null && + toggledStyle.isNotEmpty && + delta.isNotEmpty && + delta.length <= 2 && + delta.last.isInsert) { + Delta retainDelta = Delta() + ..retain(index) + ..retain(data is String ? data.length : 1, toggledStyle.toJson()); + document.compose(retainDelta, ChangeSource.LOCAL); + } + } + + toggledStyle = Style(); + if (textSelection != null) { + if (delta == null) { + _updateSelection(textSelection, ChangeSource.LOCAL); + } else { + Delta user = Delta() + ..retain(index) + ..insert(data) + ..delete(len); + int positionDelta = getPositionDelta(user, delta); + _updateSelection( + textSelection.copyWith( + baseOffset: textSelection.baseOffset + positionDelta, + extentOffset: textSelection.extentOffset + positionDelta, + ), + ChangeSource.LOCAL, + ); + } + } + notifyListeners(); + } + + formatText(int index, int len, Attribute attribute) { + if (len == 0 && attribute.isInline && attribute.key != Attribute.link.key) { + toggledStyle = toggledStyle.put(attribute); + } + + Delta change = document.format(index, len, attribute); + TextSelection adjustedSelection = selection.copyWith( + baseOffset: change.transformPosition(selection.baseOffset), + extentOffset: change.transformPosition(selection.extentOffset)); + if (selection != adjustedSelection) { + _updateSelection(adjustedSelection, ChangeSource.LOCAL); + } + notifyListeners(); + } + + formatSelection(Attribute attribute) { + formatText(selection.start, selection.end - selection.start, attribute); + } + + updateSelection(TextSelection textSelection, ChangeSource source) { + _updateSelection(textSelection, source); + notifyListeners(); + } + + compose(Delta delta, TextSelection textSelection, ChangeSource source) { + if (delta.isNotEmpty) { + document.compose(delta, source); + } + if (textSelection != null) { + _updateSelection(textSelection, source); + } else { + textSelection = selection.copyWith( + baseOffset: + delta.transformPosition(selection.baseOffset, force: false), + extentOffset: + delta.transformPosition(selection.extentOffset, force: false)); + if (selection != textSelection) { + _updateSelection(textSelection, source); + } + } + notifyListeners(); + } + + @override + void dispose() { + document.close(); + super.dispose(); + } + + _updateSelection(TextSelection textSelection, ChangeSource source) { + assert(textSelection != null); + assert(source != null); + selection = textSelection; + int end = document.length - 1; + selection = selection.copyWith( + baseOffset: math.min(selection.baseOffset, end), + extentOffset: math.min(selection.extentOffset, end)); + } +} diff --git a/lib/widgets/delegate.dart b/lib/widgets/delegate.dart new file mode 100644 index 00000000..845fdf21 --- /dev/null +++ b/lib/widgets/delegate.dart @@ -0,0 +1,31 @@ +import 'package:flutter/cupertino.dart'; + +import 'editor.dart'; + +abstract class EditorTextSelectionGestureDetectorBuilderDelegate { + GlobalKey getEditableTextKey(); + + bool getForcePressEnabled(); + + bool getSelectionEnabled(); +} + +class EditorTextSelectionGestureDetectorBuilder { + final EditorTextSelectionGestureDetectorBuilderDelegate delegate; + bool shouldShowSelectionToolbar = true; + + EditorTextSelectionGestureDetectorBuilder(this.delegate) + : assert(delegate != null); + + EditorState getEditor() { + return delegate.getEditableTextKey().currentState; + } + + RenderEditor getRenderEditor() { + return this.getEditor().getRenderEditor(); + } + + onTapDown(TapDownDetails details) { +// getRenderEditor().handleTapDown(details); + } +} diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart new file mode 100644 index 00000000..31200197 --- /dev/null +++ b/lib/widgets/editor.dart @@ -0,0 +1,247 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_quill/models/documents/document.dart'; +import 'package:flutter_quill/widgets/text_selection.dart'; + +import 'controller.dart'; +import 'delegate.dart'; + +abstract class RenderAbstractEditor { + TextSelection selectWordAtPosition(TextPosition position); + + TextSelection selectLineAtPosition(TextPosition position); + + double preferredLineHeight(TextPosition position); + + TextPosition getPositionForOffset(Offset offset); + + List getEndpointsForSelection( + TextSelection textSelection); + + void handleTapDown(TapDownDetails details); + + void selectWordsInRange( + Offset from, + Offset to, + SelectionChangedCause cause, + ); + + void selectWordEdge(SelectionChangedCause cause); + + void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause); + + void selectWord(SelectionChangedCause cause); + + void selectPosition(SelectionChangedCause cause); +} + +class QuillEditor extends StatefulWidget { + final QuillController controller; + final FocusNode focusNode; + final ScrollController scrollController; + final bool scrollable; + final EdgeInsetsGeometry padding; + final bool autoFocus; + final bool showCursor; + final bool readOnly; + final bool enableInteractiveSelection; + final double minHeight; + final double maxHeight; + final bool expands; + final TextCapitalization textCapitalization; + final Brightness keyboardAppearance; + final ScrollPhysics scrollPhysics; + final ValueChanged onLaunchUrl; + + QuillEditor( + this.controller, + this.focusNode, + this.scrollController, + this.scrollable, + this.padding, + this.autoFocus, + this.showCursor, + this.readOnly, + this.enableInteractiveSelection, + this.minHeight, + this.maxHeight, + this.expands, + this.textCapitalization, + this.keyboardAppearance, + this.scrollPhysics, + this.onLaunchUrl) + : assert(controller != null), + assert(scrollController != null), + assert(scrollable != null), + assert(autoFocus != null), + assert(readOnly != null); + + @override + _QuillEditorState createState() => _QuillEditorState(); +} + +class _QuillEditorState extends State + implements EditorTextSelectionGestureDetectorBuilderDelegate { + final GlobalKey _editorKey = GlobalKey(); + EditorTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; + + @override + Widget build(BuildContext context) { + // TODO: implement build + throw UnimplementedError(); + } + + @override + GlobalKey getEditableTextKey() { + return _editorKey; + } + + @override + bool getForcePressEnabled() { + return false; + } + + @override + bool getSelectionEnabled() { + return widget.enableInteractiveSelection; + } +} + +class RawEditor extends StatefulWidget { + @override + State createState() { + // TODO: implement createState + throw UnimplementedError(); + } +} + +class RenderEditor extends RenderEditableContainerBox + implements RenderAbstractEditor { + Document document; + TextSelection selection; + bool _hasFocus = false; + + setDocument(Document doc) { + assert(doc != null); + if (document == doc) { + return; + } + document = doc; + markNeedsLayout(); + } + + setHasFocus(bool h) { + assert(h != null); + if (_hasFocus == h) { + return; + } + _hasFocus = h; + markNeedsSemanticsUpdate(); + } + + setSelection(TextSelection t) { + if (selection == t) { + return; + } + selection = t; + markNeedsPaint(); + } + + @override + List getEndpointsForSelection( + TextSelection textSelection) { + // TODO: implement getEndpointsForSelection + throw UnimplementedError(); + } + + @override + TextPosition getPositionForOffset(Offset offset) { + // TODO: implement getPositionForOffset + throw UnimplementedError(); + } + + @override + void handleTapDown(TapDownDetails details) { + // TODO: implement handleTapDown + } + + @override + double preferredLineHeight(TextPosition position) { + // TODO: implement preferredLineHeight + throw UnimplementedError(); + } + + @override + TextSelection selectLineAtPosition(TextPosition position) { + // TODO: implement selectLineAtPosition + throw UnimplementedError(); + } + + @override + void selectPosition(SelectionChangedCause cause) { + // TODO: implement selectPosition + } + + @override + void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause) { + // TODO: implement selectPositionAt + } + + @override + void selectWord(SelectionChangedCause cause) { + // TODO: implement selectWord + } + + @override + TextSelection selectWordAtPosition(TextPosition position) { + // TODO: implement selectWordAtPosition + throw UnimplementedError(); + } + + @override + void selectWordEdge(SelectionChangedCause cause) { + // TODO: implement selectWordEdge + } + + @override + void selectWordsInRange(Offset from, Offset to, SelectionChangedCause cause) { + // TODO: implement selectWordsInRange + } +} + +class RenderEditableContainerBox extends RenderBox { + Container _container; + TextDirection _textDirection; + + setContainer(Container c) { + assert(c != null); + if (_container == c) { + return; + } + _container = c; + markNeedsLayout(); + } + + setTextDirection(TextDirection t) { + if (_textDirection == t) { + return; + } + _textDirection = t; + } +} + +abstract class EditorState extends State { + TextEditingValue getTextEditingValue(); + + void setTextEditingValue(TextEditingValue value); + + RenderEditor getRenderEditor(); + + EditorTextSelectionOverlay getSelectionOverlay(); + + bool showToolbar(); + + void hideToolbar(); + + void requestKeyboard(); +} diff --git a/lib/widgets/keyborad_listener.dart b/lib/widgets/keyborad_listener.dart new file mode 100644 index 00000000..be72827f --- /dev/null +++ b/lib/widgets/keyborad_listener.dart @@ -0,0 +1,100 @@ +import 'package:flutter/services.dart'; + +enum InputShortcut { CUT, COPY, PAST, SELECT_ALL } + +typedef CursorMoveCallback = void Function(LogicalKeyboardKey key, + {bool wordModifier, bool lineModifier, bool shift}); +typedef InputShortcutCallback = void Function(InputShortcut shortcut); +typedef OnDeleteCallback = void Function(bool forward); + +class KeyboardListener { + final CursorMoveCallback onCursorMove; + final InputShortcutCallback onShortcut; + final OnDeleteCallback onDelete; + static final Set _moveKeys = { + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + }; + + static final Set _shortcutKeys = { + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyC, + LogicalKeyboardKey.keyV, + LogicalKeyboardKey.keyX, + LogicalKeyboardKey.delete, + LogicalKeyboardKey.backspace, + }; + + static final Set _nonModifierKeys = { + ..._shortcutKeys, + ..._moveKeys, + }; + + static final Set _modifierKeys = { + LogicalKeyboardKey.shift, + LogicalKeyboardKey.control, + LogicalKeyboardKey.alt, + }; + + static final Set _macOsModifierKeys = + { + LogicalKeyboardKey.shift, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.alt, + }; + + static final Set _interestingKeys = { + ..._modifierKeys, + ..._macOsModifierKeys, + ..._nonModifierKeys, + }; + + static final Map _keyToShortcut = { + LogicalKeyboardKey.keyX: InputShortcut.CUT, + LogicalKeyboardKey.keyC: InputShortcut.COPY, + LogicalKeyboardKey.keyV: InputShortcut.PAST, + LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, + }; + + KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete) + : assert(onCursorMove != null), + assert(onShortcut != null), + assert(onDelete != null); + + bool handleRawKeyEvent(RawKeyEvent event) { + if (event is! RawKeyDownEvent) { + return false; + } + + Set keysPressed = + LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); + LogicalKeyboardKey key = event.logicalKey; + bool isMacOS = event.data is RawKeyEventDataMacOs; + if (!_nonModifierKeys.contains(key) || + keysPressed + .difference(isMacOS ? _macOsModifierKeys : _modifierKeys) + .length > + 1 || + keysPressed.difference(_interestingKeys).isNotEmpty) { + return false; + } + + if (_moveKeys.contains(key)) { + onCursorMove(key, + wordModifier: isMacOS ? event.isAltPressed : event.isControlPressed, + lineModifier: isMacOS ? event.isMetaPressed : event.isAltPressed, + shift: event.isShiftPressed); + } else if (isMacOS + ? event.isMetaPressed + : event.isControlPressed && _shortcutKeys.contains(key)) { + onShortcut(_keyToShortcut[key]); + } else if (key == LogicalKeyboardKey.delete) { + onDelete(true); + } else if (key == LogicalKeyboardKey.backspace) { + onDelete(false); + } + return false; + } +} diff --git a/lib/widgets/text_selection.dart b/lib/widgets/text_selection.dart new file mode 100644 index 00000000..c5dc4180 --- /dev/null +++ b/lib/widgets/text_selection.dart @@ -0,0 +1,83 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; + +import 'editor.dart'; + +class EditorTextSelectionOverlay { + TextEditingValue value; + bool handlesVisible = false; + final BuildContext context; + final Widget debugRequiredFor; + final LayerLink toolbarLayerLink; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final RenderEditor renderObject; + final TextSelectionControls selectionCtrls; + final TextSelectionDelegate selectionDelegate; + final DragStartBehavior dragStartBehavior; + final VoidCallback onSelectionHandleTapped; + final ClipboardStatusNotifier clipboardStatus; + AnimationController _toolbarController; + List _handles; + OverlayEntry _toolbar; + + EditorTextSelectionOverlay( + this.value, + this.handlesVisible, + this.context, + this.debugRequiredFor, + this.toolbarLayerLink, + this.startHandleLayerLink, + this.endHandleLayerLink, + this.renderObject, + this.selectionCtrls, + this.selectionDelegate, + this.dragStartBehavior, + this.onSelectionHandleTapped, + this.clipboardStatus) + : assert(value != null), + assert(context != null), + assert(handlesVisible != null) { + OverlayState overlay = Overlay.of(context, rootOverlay: true); + assert( + overlay != null, + ); + _toolbarController = AnimationController( + duration: Duration(milliseconds: 150), vsync: overlay); + } + + setHandlesVisible(bool visible) {} + + hideHandles() { + if (_handles == null) { + return; + } + _handles[0].remove(); + _handles[1].remove(); + _handles = null; + } + + /// Shows the toolbar by inserting it into the [context]'s overlay. + showToolbar() { + assert(_toolbar == null); + _toolbar = OverlayEntry(builder: _buildToolbar); + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) + .insert(_toolbar); + _toolbarController.forward(from: 0.0); + } + + Widget _buildToolbar(BuildContext context) { + if (selectionCtrls == null) { + return Container(); + } + } + + _markNeedsBuild() { + if (_handles != null) { + _handles[0].markNeedsBuild(); + _handles[1].markNeedsBuild(); + } + _toolbar?.markNeedsBuild(); + } +}