From e49421f48c8dbd30b40ed056c8edd5ab24dc5688 Mon Sep 17 00:00:00 2001 From: kevinDespoulains <46108869+kevinDespoulains@users.noreply.github.com> Date: Mon, 26 Apr 2021 17:50:46 +0200 Subject: [PATCH 1/6] Updating checkbox to handle tap (#186) * updating checkbox to handle tap * updating checkbox to handle long press and using UniqueKey() to avoid weird side effects * removed useless doc Co-authored-by: Kevin Despoulains --- lib/widgets/editor.dart | 32 +---------------- lib/widgets/raw_editor.dart | 44 +++++++++++++++-------- lib/widgets/text_block.dart | 70 ++++++++++++++++++++----------------- 3 files changed, 68 insertions(+), 78 deletions(-) diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index d15a8789..f6dd8e40 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 35e3aa68..f93eabe2 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -579,6 +579,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 = {}; @@ -589,21 +601,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.'); 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, + ), ), ); } From 760b4def7ea0c4ef39857572d16e329aad3f6dfb Mon Sep 17 00:00:00 2001 From: em6m6e <50019687+em6m6e@users.noreply.github.com> Date: Mon, 26 Apr 2021 17:52:37 +0200 Subject: [PATCH 2/6] Simple viewer (#187) * 2021-04-25 * 2021-04-26 --- lib/widgets/simple_viewer.dart | 337 +++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 lib/widgets/simple_viewer.dart diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart new file mode 100644 index 00000000..97cdcedd --- /dev/null +++ b/lib/widgets/simple_viewer.dart @@ -0,0 +1,337 @@ +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); + result.add(editableTextBlock); + } else { + throw StateError('Unreachable.'); + } + } + return result; + } + + 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); + } +} From 06c75637682053f2f71250d6622e2d91b1213527 Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Mon, 26 Apr 2021 09:07:04 -0700 Subject: [PATCH 3/6] Fix simple viewer compilation error --- lib/widgets/simple_viewer.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart index 97cdcedd..ee1a7732 100644 --- a/lib/widgets/simple_viewer.dart +++ b/lib/widgets/simple_viewer.dart @@ -204,7 +204,8 @@ class _QuillSimpleViewerState extends State : null, embedBuilder, _cursorCont, - indentLevelCounts); + indentLevelCounts, + _handleCheckboxTap); result.add(editableTextBlock); } else { throw StateError('Unreachable.'); @@ -213,6 +214,12 @@ class _QuillSimpleViewerState extends State 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; From 2adebbe11bbb54126ef9322f959adb02ad2a770a Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Mon, 26 Apr 2021 09:08:47 -0700 Subject: [PATCH 4/6] Upgrade version - checkbox supports tapping --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d96c8386..62e3a30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.2.2] +* Checkbox supports tapping. + ## [1.2.1] * Indented position not holding while editing. diff --git a/pubspec.yaml b/pubspec.yaml index 5e87f6d7..de0226ba 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.2.2 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill From 08412c167a85310293df854be1a1c4d51bf0e9b2 Mon Sep 17 00:00:00 2001 From: Gyuri Majercsik Date: Mon, 26 Apr 2021 20:09:03 +0300 Subject: [PATCH 5/6] 171: support for non-scrollable text editor (#188) Co-authored-by: Gyuri Majercsik --- lib/widgets/editor.dart | 1 - lib/widgets/raw_editor.dart | 33 ++++++++++++++++++--------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index f6dd8e40..662a018c 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -1043,7 +1043,6 @@ class RenderEditableContainerBox extends RenderBox @override void performLayout() { - assert(!constraints.hasBoundedHeight); assert(constraints.hasBoundedWidth); _resolvePadding(); assert(_resolvedPadding != null); diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index f93eabe2..4df93a4d 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -1008,25 +1008,28 @@ class RawEditorState extends EditorState _showCaretOnScreenScheduled = true; SchedulerBinding.instance!.addPostFrameCallback((_) { - _showCaretOnScreenScheduled = false; + if (widget.scrollable) { + _showCaretOnScreenScheduled = false; - final viewport = RenderAbstractViewport.of(getRenderEditor())!; - final editorOffset = getRenderEditor()! - .localToGlobal(const Offset(0, 0), ancestor: viewport); - final offsetInViewport = _scrollController!.offset + editorOffset.dy; + final viewport = RenderAbstractViewport.of(getRenderEditor()); - final offset = getRenderEditor()!.getOffsetToRevealCursor( - _scrollController!.position.viewportDimension, - _scrollController!.offset, - offsetInViewport, - ); + final editorOffset = getRenderEditor()! + .localToGlobal(const Offset(0, 0), ancestor: viewport); + final offsetInViewport = _scrollController!.offset + editorOffset.dy; - if (offset != null) { - _scrollController!.animateTo( - offset, - duration: const Duration(milliseconds: 100), - curve: Curves.fastOutSlowIn, + final offset = getRenderEditor()!.getOffsetToRevealCursor( + _scrollController!.position.viewportDimension, + _scrollController!.offset, + offsetInViewport, ); + + if (offset != null) { + _scrollController!.animateTo( + offset, + duration: const Duration(milliseconds: 100), + curve: Curves.fastOutSlowIn, + ); + } } }); } From f816ad7ec84a2166c28d12b3a85acc88d0429e0e Mon Sep 17 00:00:00 2001 From: hyouuu Date: Mon, 3 May 2021 01:34:01 -0700 Subject: [PATCH 6/6] custom rules & optionally auto add newline for image embeds (#205) --- lib/models/documents/document.dart | 31 ++++++++++++++++++++---------- lib/models/rules/rule.dart | 8 +++++++- lib/widgets/controller.dart | 4 ++-- 3 files changed, 30 insertions(+), 13 deletions(-) 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 &&