diff --git a/lib/models/documents/document.dart b/lib/models/documents/document.dart index 0362665e..072b3a44 100644 --- a/lib/models/documents/document.dart +++ b/lib/models/documents/document.dart @@ -47,7 +47,7 @@ class Document { Stream> get changes => _observer.stream; - Delta insert(int index, Object? data) { + Delta insert(int index, Object? data, {int replaceLength = 0}) { assert(index >= 0); assert(data is String || data is Embeddable); if (data is Embeddable) { @@ -56,7 +56,7 @@ class Document { return Delta(); } - final delta = _rules.apply(RuleType.INSERT, this, index, data: data); + final delta = _rules.apply(RuleType.INSERT, this, index, data: data, len: replaceLength); compose(delta, ChangeSource.LOCAL); return delta; } @@ -80,8 +80,10 @@ class Document { var delta = Delta(); + // We have to insert before applying delete rules + // Otherwise delete would be operating on stale document snapshot. if (dataIsNotEmpty) { - delta = insert(index + len, data); + delta = insert(index, data, replaceLength: len); } if (len > 0) { diff --git a/lib/models/rules/insert.dart b/lib/models/rules/insert.dart index f7d029a4..b83c8838 100644 --- a/lib/models/rules/insert.dart +++ b/lib/models/rules/insert.dart @@ -13,7 +13,6 @@ abstract class InsertRule extends Rule { @override void validateArgs(int? len, Object? data, Attribute? attribute) { - assert(len == null); assert(data != null); assert(attribute == null); } @@ -43,7 +42,7 @@ class PreserveLineStyleOnSplitRule extends InsertRule { final text = after.data as String; - final delta = Delta()..retain(index); + final delta = Delta()..retain(index + (len ?? 0)); if (text.contains('\n')) { assert(after.isPlain); delta.insert('\n'); @@ -86,7 +85,7 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { } final lines = data.split('\n'); - final delta = Delta()..retain(index); + final delta = Delta()..retain(index + (len ?? 0)); for (var i = 0; i < lines.length; i++) { final line = lines[i]; if (line.isNotEmpty) { @@ -157,7 +156,7 @@ class AutoExitBlockRule extends InsertRule { .firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k)); attributes[k] = null; // retain(1) should be '\n', set it with no attribute - return Delta()..retain(index)..retain(1, attributes); + return Delta()..retain(index + (len ?? 0))..retain(1, attributes); } } @@ -183,7 +182,7 @@ class ResetLineFormatOnNewLineRule extends InsertRule { resetStyle = Attribute.header.toJson(); } return Delta() - ..retain(index) + ..retain(index + (len ?? 0)) ..insert('\n', cur.attributes) ..retain(1, resetStyle) ..trim(); @@ -200,7 +199,7 @@ class InsertEmbedsRule extends InsertRule { return null; } - final delta = Delta()..retain(index); + final delta = Delta()..retain(index + (len ?? 0)); final itr = DeltaIterator(document); final prev = itr.skip(index), cur = itr.next(); @@ -258,7 +257,7 @@ class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { if (!cursorBeforeEmbed && !cursorAfterEmbed) { return null; } - final delta = Delta()..retain(index); + final delta = Delta()..retain(index + (len ?? 0)); if (cursorBeforeEmbed && !text.endsWith('\n')) { return delta..insert(text)..insert('\n'); } @@ -299,7 +298,7 @@ class AutoFormatLinksRule extends InsertRule { attributes.addAll(LinkAttribute(link.toString()).toJson()); return Delta() - ..retain(index - cand.length) + ..retain(index + (len ?? 0) - cand.length) ..retain(cand.length, attributes) ..insert(data, prev.attributes); } on FormatException { @@ -330,13 +329,13 @@ class PreserveInlineStylesRule extends InsertRule { final text = data; if (attributes == null || !attributes.containsKey(Attribute.link.key)) { return Delta() - ..retain(index) + ..retain(index + (len ?? 0)) ..insert(text, attributes); } attributes.remove(Attribute.link.key); final delta = Delta() - ..retain(index) + ..retain(index + (len ?? 0)) ..insert(text, attributes.isEmpty ? null : attributes); final next = itr.next(); @@ -346,7 +345,7 @@ class PreserveInlineStylesRule extends InsertRule { } if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) { return Delta() - ..retain(index) + ..retain(index + (len ?? 0)) ..insert(text, attributes); } return delta; @@ -360,7 +359,7 @@ class CatchAllInsertRule extends InsertRule { Delta applyRule(Delta document, int index, {int? len, Object? data, Attribute? attribute}) { return Delta() - ..retain(index) + ..retain(index + (len ?? 0)) ..insert(data); } } diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index 4d2ce846..53540eb4 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -119,6 +119,7 @@ class QuillEditor extends StatefulWidget { required this.focusNode, required this.scrollController, required this.scrollable, + required this.scrollBottomInset, required this.padding, required this.autoFocus, required this.readOnly, @@ -133,7 +134,12 @@ class QuillEditor extends StatefulWidget { this.keyboardAppearance = Brightness.light, this.scrollPhysics, this.onLaunchUrl, - this.embedBuilder = _defaultEmbedBuilder, + this.onTapDown, + this.onTapUp, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.embedBuilder = _defaultEmbedBuilder }); factory QuillEditor.basic({ @@ -148,6 +154,7 @@ class QuillEditor extends StatefulWidget { autoFocus: true, readOnly: readOnly, expands: false, + scrollBottomInset: 0, padding: EdgeInsets.zero); } @@ -155,6 +162,7 @@ class QuillEditor extends StatefulWidget { final FocusNode focusNode; final ScrollController scrollController; final bool scrollable; + final double scrollBottomInset; final EdgeInsetsGeometry padding; final bool autoFocus; final bool? showCursor; @@ -169,6 +177,16 @@ class QuillEditor extends StatefulWidget { final Brightness keyboardAppearance; final ScrollPhysics? scrollPhysics; final ValueChanged? onLaunchUrl; + // Returns whether gesture is handled + final bool Function(TapDownDetails details, TextPosition textPosition)? onTapDown; + // Returns whether gesture is handled + final bool Function(TapUpDetails details, TextPosition textPosition)? onTapUp; + // Returns whether gesture is handled + final bool Function(LongPressStartDetails details, TextPosition textPosition)? onSingleLongTapStart; + // Returns whether gesture is handled + final bool Function(LongPressMoveUpdateDetails details, TextPosition textPosition)? onSingleLongTapMoveUpdate; + // Returns whether gesture is handled + final bool Function(LongPressEndDetails details, TextPosition textPosition)? onSingleLongTapEnd; final EmbedBuilder embedBuilder; @override @@ -239,6 +257,7 @@ class _QuillEditorState extends State widget.focusNode, widget.scrollController, widget.scrollable, + widget.scrollBottomInset, widget.padding, widget.readOnly, widget.placeholder, @@ -315,6 +334,15 @@ class _QuillEditorSelectionGestureDetectorBuilder @override void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (_state.widget.onSingleLongTapMoveUpdate != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onSingleLongTapMoveUpdate!(details, renderEditor.getPositionForOffset(details.globalPosition) + )) { + return; + } + } + } if (!delegate.getSelectionEnabled()) { return; } @@ -434,8 +462,30 @@ class _QuillEditorSelectionGestureDetectorBuilder await launch(url); } + @override + void onTapDown(TapDownDetails details) { + if (_state.widget.onTapDown != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onTapDown!(details, renderEditor.getPositionForOffset(details.globalPosition))) { + return; + } + } + } + super.onTapDown(details); + } + @override void onSingleTapUp(TapUpDetails details) { + if (_state.widget.onTapUp != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onTapUp!(details, renderEditor.getPositionForOffset(details.globalPosition))) { + return; + } + } + } + getEditor()!.hideToolbar(); final positionSelected = _onTapping(details); @@ -469,6 +519,15 @@ class _QuillEditorSelectionGestureDetectorBuilder @override void onSingleLongTapStart(LongPressStartDetails details) { + if (_state.widget.onSingleLongTapStart != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onSingleLongTapStart!(details, renderEditor.getPositionForOffset(details.globalPosition))) { + return; + } + } + } + if (delegate.getSelectionEnabled()) { switch (Theme.of(_state.context).platform) { case TargetPlatform.iOS: @@ -491,6 +550,19 @@ class _QuillEditorSelectionGestureDetectorBuilder } } } + + @override + void onSingleLongTapEnd(LongPressEndDetails details) { + if (_state.widget.onSingleLongTapEnd != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onSingleLongTapEnd!(details, renderEditor.getPositionForOffset(details.globalPosition))) { + return; + } + } + } + super.onSingleLongTapEnd(details); + } } typedef TextSelectionChangedHandler = void Function( @@ -501,6 +573,7 @@ class RenderEditor extends RenderEditableContainerBox RenderEditor( List? children, TextDirection textDirection, + double scrollBottomInset, EdgeInsetsGeometry padding, this.document, this.selection, @@ -513,6 +586,7 @@ class RenderEditor extends RenderEditableContainerBox children, document.root, textDirection, + scrollBottomInset, padding, ); @@ -571,6 +645,14 @@ class RenderEditor extends RenderEditableContainerBox markNeedsPaint(); } + void setScrollBottomInset(double value) { + if (scrollBottomInset == value) { + return; + } + scrollBottomInset = value; + markNeedsPaint(); + } + @override List getEndpointsForSelection( TextSelection textSelection) { @@ -843,8 +925,9 @@ class RenderEditor extends RenderEditableContainerBox child.preferredLineHeight(TextPosition( offset: selection.extentOffset - child.getContainer().offset)) - kMargin + - offsetInViewport; - final caretBottom = endpoint.point.dy + kMargin + offsetInViewport; + offsetInViewport + + scrollBottomInset; + final caretBottom = endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset; double? dy; if (caretTop < scrollOffset) { dy = caretTop; @@ -871,6 +954,7 @@ class RenderEditableContainerBox extends RenderBox List? children, this._container, this.textDirection, + this.scrollBottomInset, this._padding, ) : assert(_padding.isNonNegative) { addAll(children); @@ -879,6 +963,7 @@ class RenderEditableContainerBox extends RenderBox container_node.Container _container; TextDirection textDirection; EdgeInsetsGeometry _padding; + double scrollBottomInset; EdgeInsets? _resolvedPadding; container_node.Container getContainer() { diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index 0ae9e82d..1627ebc3 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -34,6 +34,7 @@ class RawEditor extends StatefulWidget { this.focusNode, this.scrollController, this.scrollable, + this.scrollBottomInset, this.padding, this.readOnly, this.placeholder, @@ -65,6 +66,7 @@ class RawEditor extends StatefulWidget { final FocusNode focusNode; final ScrollController scrollController; final bool scrollable; + final double scrollBottomInset; final EdgeInsetsGeometry padding; final bool readOnly; final String? placeholder; @@ -527,6 +529,7 @@ class RawEditorState extends EditorState startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, onSelectionChanged: _handleSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, padding: widget.padding, children: _buildChildren(_doc, context), ), @@ -588,6 +591,7 @@ class RawEditorState extends EditorState final editableTextBlock = EditableTextBlock( node, _textDirection, + widget.scrollBottomInset, _getVerticalSpacingForBlock(node, _styles), widget.controller.selection, widget.selectionColor, @@ -1137,6 +1141,7 @@ class _Editor extends MultiChildRenderObjectWidget { required this.startHandleLayerLink, required this.endHandleLayerLink, required this.onSelectionChanged, + required this.scrollBottomInset, this.padding = EdgeInsets.zero, }) : super(key: key, children: children); @@ -1147,6 +1152,7 @@ class _Editor extends MultiChildRenderObjectWidget { final LayerLink startHandleLayerLink; final LayerLink endHandleLayerLink; final TextSelectionChangedHandler onSelectionChanged; + final double scrollBottomInset; final EdgeInsetsGeometry padding; @override @@ -1154,6 +1160,7 @@ class _Editor extends MultiChildRenderObjectWidget { return RenderEditor( null, textDirection, + scrollBottomInset, padding, document, selection, @@ -1177,6 +1184,7 @@ class _Editor extends MultiChildRenderObjectWidget { ..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 1c81f1ac..d826c9e5 100644 --- a/lib/widgets/text_block.dart +++ b/lib/widgets/text_block.dart @@ -50,6 +50,7 @@ class EditableTextBlock extends StatelessWidget { const EditableTextBlock( this.block, this.textDirection, + this.scrollBottomInset, this.verticalSpacing, this.textSelection, this.color, @@ -64,6 +65,7 @@ class EditableTextBlock extends StatelessWidget { final Block block; final TextDirection textDirection; + final double scrollBottomInset; final Tuple2 verticalSpacing; final TextSelection textSelection; final Color color; @@ -84,6 +86,7 @@ class EditableTextBlock extends StatelessWidget { block, textDirection, verticalSpacing as Tuple2, + scrollBottomInset, _getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(), contentPadding, _buildChildren(context, indentLevelCounts)); @@ -256,6 +259,7 @@ class RenderEditableTextBlock extends RenderEditableContainerBox required Block block, required TextDirection textDirection, required EdgeInsetsGeometry padding, + required double scrollBottomInset, required Decoration decoration, List? children, ImageConfiguration configuration = ImageConfiguration.empty, @@ -268,6 +272,7 @@ class RenderEditableTextBlock extends RenderEditableContainerBox children, block, textDirection, + scrollBottomInset, padding.add(contentPadding), ); @@ -512,14 +517,16 @@ class _EditableBlock extends MultiChildRenderObjectWidget { this.block, this.textDirection, this.padding, + this.scrollBottomInset, this.decoration, this.contentPadding, - List children, + List children ) : super(children: children); final Block block; final TextDirection textDirection; final Tuple2 padding; + final double scrollBottomInset; final Decoration decoration; final EdgeInsets? contentPadding; @@ -534,6 +541,7 @@ class _EditableBlock extends MultiChildRenderObjectWidget { block: block, textDirection: textDirection, padding: _padding, + scrollBottomInset: scrollBottomInset, decoration: decoration, contentPadding: _contentPadding, ); @@ -545,6 +553,7 @@ class _EditableBlock extends MultiChildRenderObjectWidget { renderObject ..setContainer(block) ..textDirection = textDirection + ..scrollBottomInset = scrollBottomInset ..setPadding(_padding) ..decoration = decoration ..contentPadding = _contentPadding; diff --git a/lib/widgets/text_selection.dart b/lib/widgets/text_selection.dart index dbf9d856..a8748de1 100644 --- a/lib/widgets/text_selection.dart +++ b/lib/widgets/text_selection.dart @@ -533,6 +533,7 @@ class _EditorTextSelectionGestureDetectorState } void _handleTapDown(TapDownDetails details) { + // renderObject.resetTapDownStatus(); if (widget.onTapDown != null) { widget.onTapDown!(details); } diff --git a/lib/widgets/toolbar.dart b/lib/widgets/toolbar.dart index 71ddec1d..11753e8c 100644 --- a/lib/widgets/toolbar.dart +++ b/lib/widgets/toolbar.dart @@ -24,11 +24,13 @@ class InsertEmbedButton extends StatelessWidget { const InsertEmbedButton({ required this.controller, required this.icon, + this.fillColor, Key? key, }) : super(key: key); final QuillController controller; final IconData icon; + final Color? fillColor; @override Widget build(BuildContext context) { @@ -41,7 +43,7 @@ class InsertEmbedButton extends StatelessWidget { size: iconSize, color: Theme.of(context).iconTheme.color, ), - fillColor: Theme.of(context).canvasColor, + fillColor: fillColor ?? Theme.of(context).canvasColor, onPressed: () { final index = controller.selection.baseOffset; final length = controller.selection.extentOffset - index; @@ -169,6 +171,7 @@ typedef ToggleStyleButtonBuilder = Widget Function( BuildContext context, Attribute attribute, IconData icon, + Color? fillColor, bool? isToggled, VoidCallback? onPressed, ); @@ -178,6 +181,7 @@ class ToggleStyleButton extends StatefulWidget { required this.attribute, required this.icon, required this.controller, + this.fillColor, this.childBuilder = defaultToggleStyleButtonBuilder, Key? key, }) : super(key: key); @@ -186,6 +190,8 @@ class ToggleStyleButton extends StatefulWidget { final IconData icon; + final Color? fillColor; + final QuillController controller; final ToggleStyleButtonBuilder childBuilder; @@ -246,8 +252,7 @@ class _ToggleStyleButtonState extends State { _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); final isEnabled = !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; - return widget.childBuilder(context, widget.attribute, widget.icon, - _isToggled, isEnabled ? _toggleAttribute : null); + return widget.childBuilder(context, widget.attribute, widget.icon, widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); } void _toggleAttribute() { @@ -262,12 +267,15 @@ class ToggleCheckListButton extends StatefulWidget { required this.icon, required this.controller, required this.attribute, + this.fillColor, this.childBuilder = defaultToggleStyleButtonBuilder, Key? key, }) : super(key: key); final IconData icon; + final Color? fillColor; + final QuillController controller; final ToggleStyleButtonBuilder childBuilder; @@ -331,8 +339,7 @@ class _ToggleCheckListButtonState extends State { _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); final isEnabled = !isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; - return widget.childBuilder(context, Attribute.unchecked, widget.icon, - _isToggled, isEnabled ? _toggleAttribute : null); + return widget.childBuilder(context, Attribute.unchecked, widget.icon, widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); } void _toggleAttribute() { @@ -346,6 +353,7 @@ Widget defaultToggleStyleButtonBuilder( BuildContext context, Attribute attribute, IconData icon, + Color? fillColor, bool? isToggled, VoidCallback? onPressed, ) { @@ -356,14 +364,14 @@ Widget defaultToggleStyleButtonBuilder( ? theme.primaryIconTheme.color : theme.iconTheme.color : theme.disabledColor; - final fillColor = - isToggled == true ? theme.toggleableActiveColor : theme.canvasColor; + final fill = + isToggled == true ? theme.toggleableActiveColor : fillColor ?? theme.canvasColor; return QuillIconButton( highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, icon: Icon(icon, size: iconSize, color: iconColor), - fillColor: fillColor, + fillColor: fill, onPressed: onPressed, ); }