From da2a05aaa0baef057750426f5b4ca749060c2be2 Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Mon, 26 Apr 2021 00:45:40 -0700 Subject: [PATCH 01/45] Update issue templates --- .github/ISSUE_TEMPLATE/issue-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. 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 02/45] 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 03/45] 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 04/45] 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 05/45] 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 06/45] 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 07/45] 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 && From 1ac73b7cae0245f9a69bb002bcfcd334091bdaca Mon Sep 17 00:00:00 2001 From: kevinDespoulains <46108869+kevinDespoulains@users.noreply.github.com> Date: Wed, 19 May 2021 13:29:38 +0200 Subject: [PATCH 08/45] Adding missing overrides to make package work with Flutter 2.2.0 (#226) --- lib/widgets/raw_editor.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index 4df93a4d..cfd98c42 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -1050,7 +1050,7 @@ class RawEditorState extends EditorState } @override - void hideToolbar() { + void hideToolbar([bool hideHandles = true]) { if (getSelectionOverlay()?.toolbar != null) { getSelectionOverlay()?.hideToolbar(); } @@ -1152,6 +1152,11 @@ class RawEditorState extends EditorState closeConnectionIfNeeded(); } } + + @override + void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) { + // TODO: implement userUpdateTextEditingValue + } } class _Editor extends MultiChildRenderObjectWidget { From 5655920937e9964d7d10bf60740b71db69bba1af Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Wed, 19 May 2021 18:47:57 +0200 Subject: [PATCH 09/45] Improve SOC of raw editor (#227) Improve separation of concerns for RawEditor by moving the code for the text input client to a separate class, furthermore add more comments. --- ..._editor_state_text_input_client_mixin.dart | 200 ++++++++++++++++++ lib/widgets/raw_editor.dart | 159 +------------- 2 files changed, 205 insertions(+), 154 deletions(-) create mode 100644 lib/widgets/controller/raw_editor_state_text_input_client_mixin.dart diff --git a/lib/widgets/controller/raw_editor_state_text_input_client_mixin.dart b/lib/widgets/controller/raw_editor_state_text_input_client_mixin.dart new file mode 100644 index 00000000..527df582 --- /dev/null +++ b/lib/widgets/controller/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/raw_editor.dart b/lib/widgets/raw_editor.dart index cfd98c42..d9df13d5 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -17,6 +17,7 @@ import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/line.dart'; import '../utils/diff_delta.dart'; import 'controller.dart'; +import 'controller/raw_editor_state_text_input_client_mixin.dart'; import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; @@ -98,12 +99,10 @@ class RawEditorState extends EditorState with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, - TickerProviderStateMixin + TickerProviderStateMixin, + RawEditorStateTextInputClientMixin implements TextSelectionDelegate, TextInputClient { final GlobalKey _editorKey = GlobalKey(); - final List _sentRemoteValues = []; - TextInputConnection? _textInputConnection; - TextEditingValue? _lastKnownRemoteTextEditingValue; int _cursorResetLocation = -1; bool _wasSelectingVerticallyWithKeyboard = false; EditorTextSelectionOverlay? _selectionOverlay; @@ -122,21 +121,6 @@ class RawEditorState extends EditorState 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 { @@ -364,105 +348,6 @@ class RawEditorState extends EditorState 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); - } - } - - @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(); @@ -473,36 +358,9 @@ class RawEditorState extends EditorState setTextEditingValue(value); } - @override - void performAction(TextInputAction action) {} - - @override - void performPrivateCommand(String action, Map data) {} - - @override - void updateFloatingCursor(RawFloatingCursorPoint point) { - throw UnimplementedError(); - } - - @override - void showAutocorrectionPromptRect(int start, int end) { - throw UnimplementedError(); - } - @override void bringIntoView(TextPosition position) {} - @override - void connectionClosed() { - if (!hasConnection) { - return; - } - _textInputConnection!.connectionClosedReceived(); - _textInputConnection = null; - _lastKnownRemoteTextEditingValue = null; - _sentRemoteValues.clear(); - } - @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); @@ -1145,16 +1003,9 @@ 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) { + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause cause) { // TODO: implement userUpdateTextEditingValue } } From 90e7f0ef434502ce23da1a49067b2f4eda2ec189 Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Wed, 19 May 2021 09:50:28 -0700 Subject: [PATCH 10/45] Upgrade version --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e3a30e..323da506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.3.0] +* Support flutter 2.2.0. + ## [1.2.2] * Checkbox supports tapping. diff --git a/pubspec.yaml b/pubspec.yaml index de0226ba..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.2 +version: 1.3.0 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill From 0c05c530e3f0815d3fce06b5e5b380394dfee8ee Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Thu, 20 May 2021 13:43:05 +0200 Subject: [PATCH 11/45] Improve SOC of raw editor (#228) Improve separation of concerns for `RawEditor` by moving the code for the keyboard to a separate class, furthermore add more comments. The PR does not change the functionality of the code. --- lib/widgets/raw_editor.dart | 310 +-------------- .../raw_editor_state_keyboard_mixin.dart | 354 ++++++++++++++++++ ..._editor_state_text_input_client_mixin.dart | 0 3 files changed, 357 insertions(+), 307 deletions(-) create mode 100644 lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart rename lib/widgets/{controller => raw_editor}/raw_editor_state_text_input_client_mixin.dart (100%) diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index d9df13d5..580dd5b7 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -15,15 +15,15 @@ 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 'controller/raw_editor_state_text_input_client_mixin.dart'; import 'cursor.dart'; import 'default_styles.dart'; 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_text_input_client_mixin.dart'; import 'text_block.dart'; import 'text_line.dart'; import 'text_selection.dart'; @@ -100,11 +100,10 @@ class RawEditorState extends EditorState AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, + RawEditorStateKeyboardMixin, RawEditorStateTextInputClientMixin implements TextSelectionDelegate, TextInputClient { final GlobalKey _editorKey = GlobalKey(); - int _cursorResetLocation = -1; - bool _wasSelectingVerticallyWithKeyboard = false; EditorTextSelectionOverlay? _selectionOverlay; FocusAttachment? _focusAttachment; late CursorCont _cursorCont; @@ -128,226 +127,6 @@ class RawEditorState extends EditorState 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; - } - @override TextEditingValue get textEditingValue { return getTextEditingValue(); @@ -654,89 +433,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(); 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/controller/raw_editor_state_text_input_client_mixin.dart b/lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart similarity index 100% rename from lib/widgets/controller/raw_editor_state_text_input_client_mixin.dart rename to lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart From ac68c2373d04aa223efe39b552e30ada81ee10a5 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Thu, 20 May 2021 13:56:46 +0200 Subject: [PATCH 12/45] Improve further SOC of raw editor This improves separation of concerns for the RawEditor by moving the code for the text selection delegate to a separate class, furthermore add more comments. The PR does not change the functionality of the code. --- lib/widgets/raw_editor.dart | 78 ++++++------------- ...editor_state_selection_delegate_mixin.dart | 40 ++++++++++ 2 files changed, 64 insertions(+), 54 deletions(-) create mode 100644 lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index 580dd5b7..cd4f379c 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -23,6 +23,7 @@ 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'; @@ -90,9 +91,7 @@ class RawEditor extends StatefulWidget { final EmbedBuilder embedBuilder; @override - State createState() { - return RawEditorState(); - } + State createState() => RawEditorState(); } class RawEditorState extends EditorState @@ -101,44 +100,39 @@ class RawEditorState extends EditorState WidgetsBindingObserver, TickerProviderStateMixin, RawEditorStateKeyboardMixin, - RawEditorStateTextInputClientMixin - implements TextSelectionDelegate, TextInputClient { + RawEditorStateTextInputClientMixin, + RawEditorStateSelectionDelegateMixin { final GlobalKey _editorKey = GlobalKey(); - 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; + + // Selection overlay + @override + EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; + EditorTextSelectionOverlay? _selectionOverlay; + + ScrollController? _scrollController; + + late CursorCont _cursorCont; + + // Focus + bool _didAutoFocus = false; + FocusAttachment? _focusAttachment; + bool get _hasFocus => widget.focusNode.hasFocus; + DefaultStyles? _styles; + final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); final LayerLink _toolbarLayerLink = LayerLink(); final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); - bool get _hasFocus => widget.focusNode.hasFocus; - - TextDirection get _textDirection { - final result = Directionality.of(context); - return result; - } - - @override - TextEditingValue get textEditingValue { - return getTextEditingValue(); - } - - @override - set textEditingValue(TextEditingValue value) { - setTextEditingValue(value); - } - - @override - void bringIntoView(TextPosition position) {} + TextDirection get _textDirection => Directionality.of(context); @override Widget build(BuildContext context) { @@ -593,35 +587,11 @@ class RawEditorState extends EditorState return _editorKey.currentContext!.findRenderObject() as RenderEditor?; } - @override - EditorTextSelectionOverlay? getSelectionOverlay() { - return _selectionOverlay; - } - @override TextEditingValue getTextEditingValue() { return widget.controller.plainTextEditingValue; } - @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; - @override void requestKeyboard() { if (_hasFocus) { 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; +} From d4e4b0d507140ed0f7745f51a0769c2f4908bef3 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Fri, 21 May 2021 18:23:21 +0200 Subject: [PATCH 13/45] Hide implementation files (#233) --- example/lib/main.dart | 1 - example/lib/pages/home_page.dart | 7 +- example/lib/pages/read_only_page.dart | 3 +- example/lib/universal_ui/universal_ui.dart | 6 +- example/lib/widgets/demo_scaffold.dart | 4 +- lib/flutter_quill.dart | 11 + lib/models/documents/attribute.dart | 295 +--- lib/models/documents/document.dart | 287 +--- lib/models/documents/history.dart | 137 +- lib/models/documents/nodes/block.dart | 75 +- lib/models/documents/nodes/container.dart | 163 +-- lib/models/documents/nodes/embed.dart | 43 +- lib/models/documents/nodes/leaf.dart | 255 +--- lib/models/documents/nodes/line.dart | 374 +---- lib/models/documents/nodes/node.dart | 134 +- lib/models/documents/style.dart | 130 +- lib/models/quill_delta.dart | 687 +-------- lib/models/rules/delete.dart | 127 +- lib/models/rules/format.dart | 135 +- lib/models/rules/insert.dart | 416 +----- lib/models/rules/rule.dart | 80 +- lib/src/models/documents/attribute.dart | 292 ++++ lib/src/models/documents/document.dart | 290 ++++ lib/src/models/documents/history.dart | 134 ++ lib/src/models/documents/nodes/block.dart | 72 + lib/src/models/documents/nodes/container.dart | 160 ++ lib/src/models/documents/nodes/embed.dart | 40 + lib/src/models/documents/nodes/leaf.dart | 252 ++++ lib/src/models/documents/nodes/line.dart | 371 +++++ lib/src/models/documents/nodes/node.dart | 131 ++ lib/src/models/documents/style.dart | 127 ++ lib/src/models/quill_delta.dart | 684 +++++++++ lib/src/models/rules/delete.dart | 124 ++ lib/src/models/rules/format.dart | 132 ++ lib/src/models/rules/insert.dart | 413 ++++++ lib/src/models/rules/rule.dart | 77 + lib/src/utils/color.dart | 125 ++ lib/src/utils/diff_delta.dart | 102 ++ lib/src/widgets/box.dart | 39 + lib/src/widgets/controller.dart | 229 +++ lib/src/widgets/cursor.dart | 231 +++ lib/src/widgets/default_styles.dart | 223 +++ lib/src/widgets/delegate.dart | 148 ++ lib/src/widgets/editor.dart | 1145 +++++++++++++++ lib/src/widgets/image.dart | 31 + lib/src/widgets/keyboard_listener.dart | 105 ++ lib/src/widgets/proxy.dart | 298 ++++ lib/src/widgets/raw_editor.dart | 736 ++++++++++ .../raw_editor_state_keyboard_mixin.dart | 354 +++++ ...editor_state_selection_delegate_mixin.dart | 40 + ..._editor_state_text_input_client_mixin.dart | 200 +++ lib/src/widgets/responsive_widget.dart | 43 + lib/src/widgets/simple_viewer.dart | 344 +++++ lib/src/widgets/text_block.dart | 737 ++++++++++ lib/src/widgets/text_line.dart | 892 ++++++++++++ lib/src/widgets/text_selection.dart | 726 +++++++++ lib/src/widgets/toolbar.dart | 1294 ++++++++++++++++ lib/utils/color.dart | 128 +- lib/utils/diff_delta.dart | 105 +- lib/widgets/box.dart | 42 +- lib/widgets/controller.dart | 231 +-- lib/widgets/cursor.dart | 234 +-- lib/widgets/default_styles.dart | 226 +-- lib/widgets/delegate.dart | 151 +- lib/widgets/editor.dart | 1148 +-------------- lib/widgets/image.dart | 34 +- lib/widgets/keyboard_listener.dart | 108 +- lib/widgets/proxy.dart | 301 +--- lib/widgets/raw_editor.dart | 739 +--------- .../raw_editor_state_keyboard_mixin.dart | 357 +---- ...editor_state_selection_delegate_mixin.dart | 43 +- ..._editor_state_text_input_client_mixin.dart | 203 +-- lib/widgets/responsive_widget.dart | 46 +- lib/widgets/simple_viewer.dart | 347 +---- lib/widgets/text_block.dart | 740 +--------- lib/widgets/text_line.dart | 895 +----------- lib/widgets/text_selection.dart | 729 +-------- lib/widgets/toolbar.dart | 1297 +---------------- 78 files changed, 11466 insertions(+), 11349 deletions(-) create mode 100644 lib/src/models/documents/attribute.dart create mode 100644 lib/src/models/documents/document.dart create mode 100644 lib/src/models/documents/history.dart create mode 100644 lib/src/models/documents/nodes/block.dart create mode 100644 lib/src/models/documents/nodes/container.dart create mode 100644 lib/src/models/documents/nodes/embed.dart create mode 100644 lib/src/models/documents/nodes/leaf.dart create mode 100644 lib/src/models/documents/nodes/line.dart create mode 100644 lib/src/models/documents/nodes/node.dart create mode 100644 lib/src/models/documents/style.dart create mode 100644 lib/src/models/quill_delta.dart create mode 100644 lib/src/models/rules/delete.dart create mode 100644 lib/src/models/rules/format.dart create mode 100644 lib/src/models/rules/insert.dart create mode 100644 lib/src/models/rules/rule.dart create mode 100644 lib/src/utils/color.dart create mode 100644 lib/src/utils/diff_delta.dart create mode 100644 lib/src/widgets/box.dart create mode 100644 lib/src/widgets/controller.dart create mode 100644 lib/src/widgets/cursor.dart create mode 100644 lib/src/widgets/default_styles.dart create mode 100644 lib/src/widgets/delegate.dart create mode 100644 lib/src/widgets/editor.dart create mode 100644 lib/src/widgets/image.dart create mode 100644 lib/src/widgets/keyboard_listener.dart create mode 100644 lib/src/widgets/proxy.dart create mode 100644 lib/src/widgets/raw_editor.dart create mode 100644 lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart create mode 100644 lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart create mode 100644 lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart create mode 100644 lib/src/widgets/responsive_widget.dart create mode 100644 lib/src/widgets/simple_viewer.dart create mode 100644 lib/src/widgets/text_block.dart create mode 100644 lib/src/widgets/text_line.dart create mode 100644 lib/src/widgets/text_selection.dart create mode 100644 lib/src/widgets/toolbar.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index f3ec0666..5b4feb2b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,3 @@ -// import 'package:app/pages/home_page.dart'; import 'package:flutter/material.dart'; import 'pages/home_page.dart'; diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 075f3f9b..d40b4be2 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -5,12 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_quill/models/documents/attribute.dart'; -import 'package:flutter_quill/models/documents/document.dart'; -import 'package:flutter_quill/widgets/controller.dart'; -import 'package:flutter_quill/widgets/default_styles.dart'; -import 'package:flutter_quill/widgets/editor.dart'; -import 'package:flutter_quill/widgets/toolbar.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:tuple/tuple.dart'; diff --git a/example/lib/pages/read_only_page.dart b/example/lib/pages/read_only_page.dart index 42957b52..594d6123 100644 --- a/example/lib/pages/read_only_page.dart +++ b/example/lib/pages/read_only_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_quill/widgets/controller.dart'; -import 'package:flutter_quill/widgets/editor.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; import '../universal_ui/universal_ui.dart'; import '../widgets/demo_scaffold.dart'; diff --git a/example/lib/universal_ui/universal_ui.dart b/example/lib/universal_ui/universal_ui.dart index b242af34..d1bdc3f5 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -2,8 +2,8 @@ library universal_ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_quill/models/documents/nodes/leaf.dart' as leaf; -import 'package:flutter_quill/widgets/responsive_widget.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + import 'package:universal_html/html.dart' as html; import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance; @@ -25,7 +25,7 @@ class UniversalUI { var ui = UniversalUI(); -Widget defaultEmbedBuilderWeb(BuildContext context, leaf.Embed node) { +Widget defaultEmbedBuilderWeb(BuildContext context, Embed node) { switch (node.value.type) { case 'image': final String imageUrl = node.value.data; diff --git a/example/lib/widgets/demo_scaffold.dart b/example/lib/widgets/demo_scaffold.dart index 4098a5f6..944b7fe9 100644 --- a/example/lib/widgets/demo_scaffold.dart +++ b/example/lib/widgets/demo_scaffold.dart @@ -2,9 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_quill/models/documents/document.dart'; -import 'package:flutter_quill/widgets/controller.dart'; -import 'package:flutter_quill/widgets/toolbar.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; typedef DemoContentBuilder = Widget Function( BuildContext context, QuillController? controller); diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 92e481c7..049786cd 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -1 +1,12 @@ library flutter_quill; + +export 'src/models/documents/attribute.dart'; +export 'src/models/documents/document.dart'; +export 'src/models/documents/nodes/embed.dart'; +export 'src/models/documents/nodes/leaf.dart'; +export 'src/models/quill_delta.dart'; +export 'src/widgets/controller.dart'; +export 'src/widgets/default_styles.dart'; +export 'src/widgets/editor.dart'; +export 'src/widgets/responsive_widget.dart'; +export 'src/widgets/toolbar.dart'; diff --git a/lib/models/documents/attribute.dart b/lib/models/documents/attribute.dart index 1b9043b9..7411e232 100644 --- a/lib/models/documents/attribute.dart +++ b/lib/models/documents/attribute.dart @@ -1,292 +1,3 @@ -import 'dart:collection'; - -import 'package:quiver/core.dart'; - -enum AttributeScope { - INLINE, // refer to https://quilljs.com/docs/formats/#inline - BLOCK, // refer to https://quilljs.com/docs/formats/#block - EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds - IGNORE, // attributes that can be ignored -} - -class Attribute { - Attribute(this.key, this.scope, this.value); - - final String key; - final AttributeScope scope; - final T value; - - static final Map _registry = LinkedHashMap.of({ - Attribute.bold.key: Attribute.bold, - Attribute.italic.key: Attribute.italic, - Attribute.underline.key: Attribute.underline, - Attribute.strikeThrough.key: Attribute.strikeThrough, - Attribute.font.key: Attribute.font, - Attribute.size.key: Attribute.size, - Attribute.link.key: Attribute.link, - Attribute.color.key: Attribute.color, - Attribute.background.key: Attribute.background, - Attribute.placeholder.key: Attribute.placeholder, - Attribute.header.key: Attribute.header, - Attribute.align.key: Attribute.align, - Attribute.list.key: Attribute.list, - Attribute.codeBlock.key: Attribute.codeBlock, - Attribute.blockQuote.key: Attribute.blockQuote, - Attribute.indent.key: Attribute.indent, - Attribute.width.key: Attribute.width, - Attribute.height.key: Attribute.height, - Attribute.style.key: Attribute.style, - Attribute.token.key: Attribute.token, - }); - - static final BoldAttribute bold = BoldAttribute(); - - static final ItalicAttribute italic = ItalicAttribute(); - - static final UnderlineAttribute underline = UnderlineAttribute(); - - static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); - - static final FontAttribute font = FontAttribute(null); - - static final SizeAttribute size = SizeAttribute(null); - - static final LinkAttribute link = LinkAttribute(null); - - static final ColorAttribute color = ColorAttribute(null); - - static final BackgroundAttribute background = BackgroundAttribute(null); - - static final PlaceholderAttribute placeholder = PlaceholderAttribute(); - - static final HeaderAttribute header = HeaderAttribute(); - - static final IndentAttribute indent = IndentAttribute(); - - static final AlignAttribute align = AlignAttribute(null); - - static final ListAttribute list = ListAttribute(null); - - static final CodeBlockAttribute codeBlock = CodeBlockAttribute(); - - static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute(); - - static final WidthAttribute width = WidthAttribute(null); - - static final HeightAttribute height = HeightAttribute(null); - - static final StyleAttribute style = StyleAttribute(null); - - static final TokenAttribute token = TokenAttribute(''); - - static final Set inlineKeys = { - Attribute.bold.key, - Attribute.italic.key, - Attribute.underline.key, - Attribute.strikeThrough.key, - Attribute.link.key, - Attribute.color.key, - Attribute.background.key, - Attribute.placeholder.key, - }; - - static final Set blockKeys = LinkedHashSet.of({ - Attribute.header.key, - Attribute.align.key, - Attribute.list.key, - Attribute.codeBlock.key, - Attribute.blockQuote.key, - Attribute.indent.key, - }); - - static final Set blockKeysExceptHeader = LinkedHashSet.of({ - Attribute.list.key, - Attribute.align.key, - Attribute.codeBlock.key, - Attribute.blockQuote.key, - Attribute.indent.key, - }); - - static Attribute get h1 => HeaderAttribute(level: 1); - - static Attribute get h2 => HeaderAttribute(level: 2); - - static Attribute get h3 => HeaderAttribute(level: 3); - - // "attributes":{"align":"left"} - static Attribute get leftAlignment => AlignAttribute('left'); - - // "attributes":{"align":"center"} - static Attribute get centerAlignment => AlignAttribute('center'); - - // "attributes":{"align":"right"} - static Attribute get rightAlignment => AlignAttribute('right'); - - // "attributes":{"align":"justify"} - static Attribute get justifyAlignment => AlignAttribute('justify'); - - // "attributes":{"list":"bullet"} - static Attribute get ul => ListAttribute('bullet'); - - // "attributes":{"list":"ordered"} - static Attribute get ol => ListAttribute('ordered'); - - // "attributes":{"list":"checked"} - static Attribute get checked => ListAttribute('checked'); - - // "attributes":{"list":"unchecked"} - static Attribute get unchecked => ListAttribute('unchecked'); - - // "attributes":{"indent":1"} - static Attribute get indentL1 => IndentAttribute(level: 1); - - // "attributes":{"indent":2"} - static Attribute get indentL2 => IndentAttribute(level: 2); - - // "attributes":{"indent":3"} - static Attribute get indentL3 => IndentAttribute(level: 3); - - static Attribute getIndentLevel(int? level) { - if (level == 1) { - return indentL1; - } - if (level == 2) { - return indentL2; - } - if (level == 3) { - return indentL3; - } - return IndentAttribute(level: level); - } - - bool get isInline => scope == AttributeScope.INLINE; - - bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key); - - Map toJson() => {key: value}; - - static Attribute fromKeyValue(String key, dynamic value) { - if (!_registry.containsKey(key)) { - throw ArgumentError.value(key, 'key "$key" not found.'); - } - final origin = _registry[key]!; - final attribute = clone(origin, value); - return attribute; - } - - static int getRegistryOrder(Attribute attribute) { - var order = 0; - for (final attr in _registry.values) { - if (attr.key == attribute.key) { - break; - } - order++; - } - - return order; - } - - static Attribute clone(Attribute origin, dynamic value) { - return Attribute(origin.key, origin.scope, value); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! Attribute) return false; - final typedOther = other; - return key == typedOther.key && - scope == typedOther.scope && - value == typedOther.value; - } - - @override - int get hashCode => hash3(key, scope, value); - - @override - String toString() { - return 'Attribute{key: $key, scope: $scope, value: $value}'; - } -} - -class BoldAttribute extends Attribute { - BoldAttribute() : super('bold', AttributeScope.INLINE, true); -} - -class ItalicAttribute extends Attribute { - ItalicAttribute() : super('italic', AttributeScope.INLINE, true); -} - -class UnderlineAttribute extends Attribute { - UnderlineAttribute() : super('underline', AttributeScope.INLINE, true); -} - -class StrikeThroughAttribute extends Attribute { - StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true); -} - -class FontAttribute extends Attribute { - FontAttribute(String? val) : super('font', AttributeScope.INLINE, val); -} - -class SizeAttribute extends Attribute { - SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val); -} - -class LinkAttribute extends Attribute { - LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val); -} - -class ColorAttribute extends Attribute { - ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val); -} - -class BackgroundAttribute extends Attribute { - BackgroundAttribute(String? val) - : super('background', AttributeScope.INLINE, val); -} - -/// This is custom attribute for hint -class PlaceholderAttribute extends Attribute { - PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true); -} - -class HeaderAttribute extends Attribute { - HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level); -} - -class IndentAttribute extends Attribute { - IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level); -} - -class AlignAttribute extends Attribute { - AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val); -} - -class ListAttribute extends Attribute { - ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val); -} - -class CodeBlockAttribute extends Attribute { - CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true); -} - -class BlockQuoteAttribute extends Attribute { - BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true); -} - -class WidthAttribute extends Attribute { - WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val); -} - -class HeightAttribute extends Attribute { - HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val); -} - -class StyleAttribute extends Attribute { - StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val); -} - -class TokenAttribute extends Attribute { - TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/attribute.dart'; diff --git a/lib/models/documents/document.dart b/lib/models/documents/document.dart index 9d966f32..d946618a 100644 --- a/lib/models/documents/document.dart +++ b/lib/models/documents/document.dart @@ -1,284 +1,3 @@ -import 'dart:async'; - -import 'package:tuple/tuple.dart'; - -import '../quill_delta.dart'; -import '../rules/rule.dart'; -import 'attribute.dart'; -import 'history.dart'; -import 'nodes/block.dart'; -import 'nodes/container.dart'; -import 'nodes/embed.dart'; -import 'nodes/line.dart'; -import 'nodes/node.dart'; -import 'style.dart'; - -/// The rich text document -class Document { - Document() : _delta = Delta()..insert('\n') { - _loadDocument(_delta); - } - - Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) { - _loadDocument(_delta); - } - - Document.fromDelta(Delta delta) : _delta = delta { - _loadDocument(delta); - } - - /// The root node of the document tree - final Root _root = Root(); - - Root get root => _root; - - int get length => _root.length; - - Delta _delta; - - Delta toDelta() => Delta.from(_delta); - - final Rules _rules = Rules.getInstance(); - - void setCustomRules(List customRules) { - _rules.setCustomRules(customRules); - } - - final StreamController> _observer = - StreamController.broadcast(); - - final History _history = History(); - - Stream> get changes => _observer.stream; - - 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) { - data = data.toJson(); - } else if ((data as String).isEmpty) { - return Delta(); - } - - final delta = _rules.apply(RuleType.INSERT, this, index, - data: data, len: replaceLength); - compose(delta, ChangeSource.LOCAL, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); - return delta; - } - - Delta delete(int index, int len) { - assert(index >= 0 && len > 0); - final delta = _rules.apply(RuleType.DELETE, this, index, len: len); - if (delta.isNotEmpty) { - compose(delta, ChangeSource.LOCAL); - } - return delta; - } - - Delta replace(int index, int len, Object? data, {bool autoAppendNewlineAfterImage = true}) { - assert(index >= 0); - assert(data is String || data is Embeddable); - - final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true; - - assert(dataIsNotEmpty || len > 0); - - 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, data, replaceLength: len, - autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); - } - - if (len > 0) { - final deleteDelta = delete(index, len); - delta = delta.compose(deleteDelta); - } - - return delta; - } - - Delta format(int index, int len, Attribute? attribute) { - assert(index >= 0 && len >= 0 && attribute != null); - - var delta = Delta(); - - final formatDelta = _rules.apply(RuleType.FORMAT, this, index, - len: len, attribute: attribute); - if (formatDelta.isNotEmpty) { - compose(formatDelta, ChangeSource.LOCAL); - delta = delta.compose(formatDelta); - } - - return delta; - } - - Style collectStyle(int index, int len) { - final res = queryChild(index); - return (res.node as Line).collectStyle(res.offset, len); - } - - ChildQuery queryChild(int offset) { - final res = _root.queryChild(offset, true); - if (res.node is Line) { - return res; - } - final block = res.node as Block; - return block.queryChild(res.offset, true); - } - - void compose(Delta delta, ChangeSource changeSource, {bool autoAppendNewlineAfterImage = true}) { - assert(!_observer.isClosed); - delta.trim(); - assert(delta.isNotEmpty); - - var offset = 0; - delta = _transform(delta, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); - final originalDelta = toDelta(); - for (final op in delta.toList()) { - final style = - op.attributes != null ? Style.fromJson(op.attributes) : null; - - if (op.isInsert) { - _root.insert(offset, _normalize(op.data), style); - } else if (op.isDelete) { - _root.delete(offset, op.length); - } else if (op.attributes != null) { - _root.retain(offset, op.length, style); - } - - if (!op.isDelete) { - offset += op.length!; - } - } - try { - _delta = _delta.compose(delta); - } catch (e) { - throw '_delta compose failed'; - } - - if (_delta != _root.toDelta()) { - throw 'Compose failed'; - } - final change = Tuple3(originalDelta, delta, changeSource); - _observer.add(change); - _history.handleDocChange(change); - } - - Tuple2 undo() { - return _history.undo(this); - } - - Tuple2 redo() { - return _history.redo(this); - } - - bool get hasUndo => _history.hasUndo; - - bool get hasRedo => _history.hasRedo; - - 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); - if (autoAppendNewlineAfterImage) { - _autoAppendNewlineAfterImage(i, ops, op, res); - } - } - return res; - } - - 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 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` - final opInsertImage = op.isInsert && op.data is! String; - final nextOpIsLineBreak = i + 1 < ops.length && - ops[i + 1].isInsert && - ops[i + 1].data is String && - (ops[i + 1].data as String).startsWith('\n'); - if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) { - // automatically append '\n' for image - res.push(Operation.insert('\n')); - } - } - - Object _normalize(Object? data) { - if (data is String) { - return data; - } - - if (data is Embeddable) { - return data; - } - return Embeddable.fromJson(data as Map); - } - - void close() { - _observer.close(); - _history.clear(); - } - - String toPlainText() => _root.children.map((e) => e.toPlainText()).join(); - - void _loadDocument(Delta doc) { - if (doc.isEmpty) { - throw ArgumentError.value(doc, 'Document Delta cannot be empty.'); - } - - assert((doc.last.data as String).endsWith('\n')); - - var offset = 0; - for (final op in doc.toList()) { - if (!op.isInsert) { - throw ArgumentError.value(doc, - 'Document Delta can only contain insert operations but ${op.key} found.'); - } - final style = - op.attributes != null ? Style.fromJson(op.attributes) : null; - final data = _normalize(op.data); - _root.insert(offset, data, style); - offset += op.length!; - } - final node = _root.last; - if (node is Line && - node.parent is! Block && - node.style.isEmpty && - _root.childCount > 1) { - _root.remove(node); - } - } - - bool isEmpty() { - if (root.children.length != 1) { - return false; - } - - final node = root.children.first; - if (!node.isLast) { - return false; - } - - final delta = node.toDelta(); - return delta.length == 1 && - delta.first.data == '\n' && - delta.first.key == 'insert'; - } -} - -enum ChangeSource { - LOCAL, - REMOTE, -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/document.dart'; diff --git a/lib/models/documents/history.dart b/lib/models/documents/history.dart index d406505e..3ff6870e 100644 --- a/lib/models/documents/history.dart +++ b/lib/models/documents/history.dart @@ -1,134 +1,3 @@ -import 'package:tuple/tuple.dart'; - -import '../quill_delta.dart'; -import 'document.dart'; - -class History { - History({ - this.ignoreChange = false, - this.interval = 400, - this.maxStack = 100, - this.userOnly = false, - this.lastRecorded = 0, - }); - - final HistoryStack stack = HistoryStack.empty(); - - bool get hasUndo => stack.undo.isNotEmpty; - - bool get hasRedo => stack.redo.isNotEmpty; - - /// used for disable redo or undo function - bool ignoreChange; - - int lastRecorded; - - /// Collaborative editing's conditions should be true - final bool userOnly; - - ///max operation count for undo - final int maxStack; - - ///record delay - final int interval; - - void handleDocChange(Tuple3 change) { - if (ignoreChange) return; - if (!userOnly || change.item3 == ChangeSource.LOCAL) { - record(change.item2, change.item1); - } else { - transform(change.item2); - } - } - - void clear() { - stack.clear(); - } - - void record(Delta change, Delta before) { - if (change.isEmpty) return; - stack.redo.clear(); - var undoDelta = change.invert(before); - final timeStamp = DateTime.now().millisecondsSinceEpoch; - - if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) { - final lastDelta = stack.undo.removeLast(); - undoDelta = undoDelta.compose(lastDelta); - } else { - lastRecorded = timeStamp; - } - - if (undoDelta.isEmpty) return; - stack.undo.add(undoDelta); - - if (stack.undo.length > maxStack) { - stack.undo.removeAt(0); - } - } - - /// - ///It will override pre local undo delta,replaced by remote change - /// - void transform(Delta delta) { - transformStack(stack.undo, delta); - transformStack(stack.redo, delta); - } - - void transformStack(List stack, Delta delta) { - for (var i = stack.length - 1; i >= 0; i -= 1) { - final oldDelta = stack[i]; - stack[i] = delta.transform(oldDelta, true); - delta = oldDelta.transform(delta, false); - if (stack[i].length == 0) { - stack.removeAt(i); - } - } - } - - Tuple2 _change(Document doc, List source, List dest) { - if (source.isEmpty) { - return const Tuple2(false, 0); - } - final delta = source.removeLast(); - // look for insert or delete - int? len = 0; - final ops = delta.toList(); - for (var i = 0; i < ops.length; i++) { - if (ops[i].key == Operation.insertKey) { - len = ops[i].length; - } else if (ops[i].key == Operation.deleteKey) { - len = ops[i].length! * -1; - } - } - final base = Delta.from(doc.toDelta()); - final inverseDelta = delta.invert(base); - dest.add(inverseDelta); - lastRecorded = 0; - ignoreChange = true; - doc.compose(delta, ChangeSource.LOCAL); - ignoreChange = false; - return Tuple2(true, len); - } - - Tuple2 undo(Document doc) { - return _change(doc, stack.undo, stack.redo); - } - - Tuple2 redo(Document doc) { - return _change(doc, stack.redo, stack.undo); - } -} - -class HistoryStack { - HistoryStack.empty() - : undo = [], - redo = []; - - final List undo; - final List redo; - - void clear() { - undo.clear(); - redo.clear(); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/history.dart'; diff --git a/lib/models/documents/nodes/block.dart b/lib/models/documents/nodes/block.dart index 095f1183..6de6f743 100644 --- a/lib/models/documents/nodes/block.dart +++ b/lib/models/documents/nodes/block.dart @@ -1,72 +1,3 @@ -import '../../quill_delta.dart'; -import 'container.dart'; -import 'line.dart'; -import 'node.dart'; - -/// Represents a group of adjacent [Line]s with the same block style. -/// -/// Block elements are: -/// - Blockquote -/// - Header -/// - Indent -/// - List -/// - Text Alignment -/// - Text Direction -/// - Code Block -class Block extends Container { - /// Creates new unmounted [Block]. - @override - Node newInstance() => Block(); - - @override - Line get defaultChild => Line(); - - @override - Delta toDelta() { - return children - .map((child) => child.toDelta()) - .fold(Delta(), (a, b) => a.concat(b)); - } - - @override - void adjust() { - if (isEmpty) { - final sibling = previous; - unlink(); - if (sibling != null) { - sibling.adjust(); - } - return; - } - - var block = this; - final prev = block.previous; - // merging it with previous block if style is the same - if (!block.isFirst && - block.previous is Block && - prev!.style == block.style) { - block - ..moveChildToNewParent(prev as Container?) - ..unlink(); - block = prev as Block; - } - final next = block.next; - // merging it with next block if style is the same - if (!block.isLast && block.next is Block && next!.style == block.style) { - (next as Block).moveChildToNewParent(block); - next.unlink(); - } - } - - @override - String toString() { - final block = style.attributes.toString(); - final buffer = StringBuffer('§ {$block}\n'); - for (final child in children) { - final tree = child.isLast ? '└' : '├'; - buffer.write(' $tree $child'); - if (!child.isLast) buffer.writeln(); - } - return buffer.toString(); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/nodes/block.dart'; diff --git a/lib/models/documents/nodes/container.dart b/lib/models/documents/nodes/container.dart index dbdd12d1..d9a54451 100644 --- a/lib/models/documents/nodes/container.dart +++ b/lib/models/documents/nodes/container.dart @@ -1,160 +1,3 @@ -import 'dart:collection'; - -import '../style.dart'; -import 'leaf.dart'; -import 'line.dart'; -import 'node.dart'; - -/// Container can accommodate other nodes. -/// -/// Delegates insert, retain and delete operations to children nodes. For each -/// operation container looks for a child at specified index position and -/// forwards operation to that child. -/// -/// Most of the operation handling logic is implemented by [Line] and [Text]. -abstract class Container extends Node { - final LinkedList _children = LinkedList(); - - /// List of children. - LinkedList get children => _children; - - /// Returns total number of child nodes in this container. - /// - /// To get text length of this container see [length]. - int get childCount => _children.length; - - /// Returns the first child [Node]. - Node get first => _children.first; - - /// Returns the last child [Node]. - Node get last => _children.last; - - /// Returns `true` if this container has no child nodes. - bool get isEmpty => _children.isEmpty; - - /// Returns `true` if this container has at least 1 child. - bool get isNotEmpty => _children.isNotEmpty; - - /// Returns an instance of default child for this container node. - /// - /// Always returns fresh instance. - T get defaultChild; - - /// Adds [node] to the end of this container children list. - void add(T node) { - assert(node?.parent == null); - node?.parent = this; - _children.add(node as Node); - } - - /// Adds [node] to the beginning of this container children list. - void addFirst(T node) { - assert(node?.parent == null); - node?.parent = this; - _children.addFirst(node as Node); - } - - /// Removes [node] from this container. - void remove(T node) { - assert(node?.parent == this); - node?.parent = null; - _children.remove(node as Node); - } - - /// Moves children of this node to [newParent]. - void moveChildToNewParent(Container? newParent) { - if (isEmpty) { - return; - } - - final last = newParent!.isEmpty ? null : newParent.last as T?; - while (isNotEmpty) { - final child = first as T; - child?.unlink(); - newParent.add(child); - } - - /// In case [newParent] already had children we need to make sure - /// combined list is optimized. - if (last != null) last.adjust(); - } - - /// Queries the child [Node] at specified character [offset] in this container. - /// - /// The result may contain the found node or `null` if no node is found - /// at specified offset. - /// - /// [ChildQuery.offset] is set to relative offset within returned child node - /// which points at the same character position in the document as the - /// original [offset]. - ChildQuery queryChild(int offset, bool inclusive) { - if (offset < 0 || offset > length) { - return ChildQuery(null, 0); - } - - for (final node in children) { - final len = node.length; - if (offset < len || (inclusive && offset == len && (node.isLast))) { - return ChildQuery(node, offset); - } - offset -= len; - } - return ChildQuery(null, 0); - } - - @override - String toPlainText() => children.map((child) => child.toPlainText()).join(); - - /// Content length of this node's children. - /// - /// To get number of children in this node use [childCount]. - @override - int get length => _children.fold(0, (cur, node) => cur + node.length); - - @override - void insert(int index, Object data, Style? style) { - assert(index == 0 || (index > 0 && index < length)); - - if (isNotEmpty) { - final child = queryChild(index, false); - child.node!.insert(child.offset, data, style); - return; - } - - // empty - assert(index == 0); - final node = defaultChild; - add(node); - node?.insert(index, data, style); - } - - @override - void retain(int index, int? length, Style? attributes) { - assert(isNotEmpty); - final child = queryChild(index, false); - child.node!.retain(child.offset, length, attributes); - } - - @override - void delete(int index, int? length) { - assert(isNotEmpty); - final child = queryChild(index, false); - child.node!.delete(child.offset, length); - } - - @override - String toString() => _children.join('\n'); -} - -/// Result of a child query in a [Container]. -class ChildQuery { - ChildQuery(this.node, this.offset); - - /// The child node if found, otherwise `null`. - final Node? node; - - /// Starting offset within the child [node] which points at the same - /// character in the document as the original offset passed to - /// [Container.queryChild] method. - final int offset; -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/nodes/container.dart'; diff --git a/lib/models/documents/nodes/embed.dart b/lib/models/documents/nodes/embed.dart index d6fe628a..01dc357b 100644 --- a/lib/models/documents/nodes/embed.dart +++ b/lib/models/documents/nodes/embed.dart @@ -1,40 +1,3 @@ -/// An object which can be embedded into a Quill document. -/// -/// See also: -/// -/// * [BlockEmbed] which represents a block embed. -class Embeddable { - Embeddable(this.type, this.data); - - /// The type of this object. - final String type; - - /// The data payload of this object. - final dynamic data; - - Map toJson() { - final m = {type: data}; - return m; - } - - static Embeddable fromJson(Map json) { - final m = Map.from(json); - assert(m.length == 1, 'Embeddable map has one key'); - - return BlockEmbed(m.keys.first, m.values.first); - } -} - -/// An object which occupies an entire line in a document and cannot co-exist -/// inline with regular text. -/// -/// There are two built-in embed types supported by Quill documents, however -/// the document model itself does not make any assumptions about the types -/// of embedded objects and allows users to define their own types. -class BlockEmbed extends Embeddable { - BlockEmbed(String type, String data) : super(type, data); - - static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr'); - - static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl); -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/nodes/embed.dart'; diff --git a/lib/models/documents/nodes/leaf.dart b/lib/models/documents/nodes/leaf.dart index bd9292f5..cc2808f2 100644 --- a/lib/models/documents/nodes/leaf.dart +++ b/lib/models/documents/nodes/leaf.dart @@ -1,252 +1,3 @@ -import 'dart:math' as math; - -import '../../quill_delta.dart'; -import '../style.dart'; -import 'embed.dart'; -import 'line.dart'; -import 'node.dart'; - -/// A leaf in Quill document tree. -abstract class Leaf extends Node { - /// Creates a new [Leaf] with specified [data]. - factory Leaf(Object data) { - if (data is Embeddable) { - return Embed(data); - } - final text = data as String; - assert(text.isNotEmpty); - return Text(text); - } - - Leaf.val(Object val) : _value = val; - - /// Contents of this node, either a String if this is a [Text] or an - /// [Embed] if this is an [BlockEmbed]. - Object get value => _value; - Object _value; - - @override - void applyStyle(Style value) { - assert(value.isInline || value.isIgnored || value.isEmpty, - 'Unable to apply Style to leaf: $value'); - super.applyStyle(value); - } - - @override - Line? get parent => super.parent as Line?; - - @override - int get length { - if (_value is String) { - return (_value as String).length; - } - // return 1 for embedded object - return 1; - } - - @override - Delta toDelta() { - final data = - _value is Embeddable ? (_value as Embeddable).toJson() : _value; - return Delta()..insert(data, style.toJson()); - } - - @override - void insert(int index, Object data, Style? style) { - assert(index >= 0 && index <= length); - final node = Leaf(data); - if (index < length) { - splitAt(index)!.insertBefore(node); - } else { - insertAfter(node); - } - node.format(style); - } - - @override - void retain(int index, int? len, Style? style) { - if (style == null) { - return; - } - - final local = math.min(length - index, len!); - final remain = len - local; - final node = _isolate(index, local); - - if (remain > 0) { - assert(node.next != null); - node.next!.retain(0, remain, style); - } - node.format(style); - } - - @override - void delete(int index, int? len) { - assert(index < length); - - final local = math.min(length - index, len!); - final target = _isolate(index, local); - final prev = target.previous as Leaf?; - final next = target.next as Leaf?; - target.unlink(); - - final remain = len - local; - if (remain > 0) { - assert(next != null); - next!.delete(0, remain); - } - - if (prev != null) { - prev.adjust(); - } - } - - /// Adjust this text node by merging it with adjacent nodes if they share - /// the same style. - @override - void adjust() { - if (this is Embed) { - // Embed nodes cannot be merged with text nor other embeds (in fact, - // there could be no two adjacent embeds on the same line since an - // embed occupies an entire line). - return; - } - - // This is a text node and it can only be merged with other text nodes. - var node = this as Text; - - // Merging it with previous node if style is the same. - final prev = node.previous; - if (!node.isFirst && prev is Text && prev.style == node.style) { - prev._value = prev.value + node.value; - node.unlink(); - node = prev; - } - - // Merging it with next node if style is the same. - final next = node.next; - if (!node.isLast && next is Text && next.style == node.style) { - node._value = node.value + next.value; - next.unlink(); - } - } - - /// Splits this leaf node at [index] and returns new node. - /// - /// If this is the last node in its list and [index] equals this node's - /// length then this method returns `null` as there is nothing left to split. - /// If there is another leaf node after this one and [index] equals this - /// node's length then the next leaf node is returned. - /// - /// If [index] equals to `0` then this node itself is returned unchanged. - /// - /// In case a new node is actually split from this one, it inherits this - /// node's style. - Leaf? splitAt(int index) { - assert(index >= 0 && index <= length); - if (index == 0) { - return this; - } - if (index == length) { - return isLast ? null : next as Leaf?; - } - - assert(this is Text); - final text = _value as String; - _value = text.substring(0, index); - final split = Leaf(text.substring(index))..applyStyle(style); - insertAfter(split); - return split; - } - - /// Cuts a leaf from [index] to the end of this node and returns new node - /// in detached state (e.g. [mounted] returns `false`). - /// - /// Splitting logic is identical to one described in [splitAt], meaning this - /// method may return `null`. - Leaf? cutAt(int index) { - assert(index >= 0 && index <= length); - final cut = splitAt(index); - cut?.unlink(); - return cut; - } - - /// Formats this node and optimizes it with adjacent leaf nodes if needed. - void format(Style? style) { - if (style != null && style.isNotEmpty) { - applyStyle(style); - } - adjust(); - } - - /// Isolates a new leaf starting at [index] with specified [length]. - /// - /// Splitting logic is identical to one described in [splitAt], with one - /// exception that it is required for [index] to always be less than this - /// node's length. As a result this method always returns a [LeafNode] - /// instance. Returned node may still be the same as this node - /// if provided [index] is `0`. - Leaf _isolate(int index, int length) { - assert( - index >= 0 && index < this.length && (index + length <= this.length)); - final target = splitAt(index)!..splitAt(length); - return target; - } -} - -/// A span of formatted text within a line in a Quill document. -/// -/// Text is a leaf node of a document tree. -/// -/// Parent of a text node is always a [Line], and as a consequence text -/// node's [value] cannot contain any line-break characters. -/// -/// See also: -/// -/// * [Embed], a leaf node representing an embeddable object. -/// * [Line], a node representing a line of text. -class Text extends Leaf { - Text([String text = '']) - : assert(!text.contains('\n')), - super.val(text); - - @override - Node newInstance() => Text(); - - @override - String get value => _value as String; - - @override - String toPlainText() => value; -} - -/// An embed node inside of a line in a Quill document. -/// -/// Embed node is a leaf node similar to [Text]. It represents an arbitrary -/// piece of non-textual content embedded into a document, such as, image, -/// horizontal rule, video, or any other object with defined structure, -/// like a tweet, for instance. -/// -/// Embed node's length is always `1` character and it is represented with -/// unicode object replacement character in the document text. -/// -/// Any inline style can be applied to an embed, however this does not -/// necessarily mean the embed will look according to that style. For instance, -/// applying "bold" style to an image gives no effect, while adding a "link" to -/// an image actually makes the image react to user's action. -class Embed extends Leaf { - Embed(Embeddable data) : super.val(data); - - static const kObjectReplacementCharacter = '\uFFFC'; - - @override - Node newInstance() => throw UnimplementedError(); - - @override - Embeddable get value => super.value as Embeddable; - - /// // Embed nodes are represented as unicode object replacement character in - // plain text. - @override - String toPlainText() => kObjectReplacementCharacter; -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/nodes/leaf.dart'; diff --git a/lib/models/documents/nodes/line.dart b/lib/models/documents/nodes/line.dart index fabfad4d..7ca2016e 100644 --- a/lib/models/documents/nodes/line.dart +++ b/lib/models/documents/nodes/line.dart @@ -1,371 +1,3 @@ -import 'dart:math' as math; - -import 'package:collection/collection.dart'; - -import '../../quill_delta.dart'; -import '../attribute.dart'; -import '../style.dart'; -import 'block.dart'; -import 'container.dart'; -import 'embed.dart'; -import 'leaf.dart'; -import 'node.dart'; - -/// A line of rich text in a Quill document. -/// -/// Line serves as a container for [Leaf]s, like [Text] and [Embed]. -/// -/// When a line contains an embed, it fully occupies the line, no other embeds -/// or text nodes are allowed. -class Line extends Container { - @override - Leaf get defaultChild => Text(); - - @override - int get length => super.length + 1; - - /// Returns `true` if this line contains an embedded object. - bool get hasEmbed { - if (childCount != 1) { - return false; - } - - return children.single is Embed; - } - - /// Returns next [Line] or `null` if this is the last line in the document. - Line? get nextLine { - if (!isLast) { - return next is Block ? (next as Block).first as Line? : next as Line?; - } - if (parent is! Block) { - return null; - } - - if (parent!.isLast) { - return null; - } - return parent!.next is Block - ? (parent!.next as Block).first as Line? - : parent!.next as Line?; - } - - @override - Node newInstance() => Line(); - - @override - Delta toDelta() { - final delta = children - .map((child) => child.toDelta()) - .fold(Delta(), (dynamic a, b) => a.concat(b)); - var attributes = style; - if (parent is Block) { - final block = parent as Block; - attributes = attributes.mergeAll(block.style); - } - delta.insert('\n', attributes.toJson()); - return delta; - } - - @override - String toPlainText() => '${super.toPlainText()}\n'; - - @override - String toString() { - final body = children.join(' → '); - final styleString = style.isNotEmpty ? ' $style' : ''; - return '¶ $body ⏎$styleString'; - } - - @override - void insert(int index, Object data, Style? style) { - if (data is Embeddable) { - // We do not check whether this line already has any children here as - // inserting an embed into a line with other text is acceptable from the - // Delta format perspective. - // We rely on heuristic rules to ensure that embeds occupy an entire line. - _insertSafe(index, data, style); - return; - } - - final text = data as String; - final lineBreak = text.indexOf('\n'); - if (lineBreak < 0) { - _insertSafe(index, text, style); - // No need to update line or block format since those attributes can only - // be attached to `\n` character and we already know it's not present. - return; - } - - final prefix = text.substring(0, lineBreak); - _insertSafe(index, prefix, style); - if (prefix.isNotEmpty) { - index += prefix.length; - } - - // Next line inherits our format. - final nextLine = _getNextLine(index); - - // Reset our format and unwrap from a block if needed. - clearStyle(); - if (parent is Block) { - _unwrap(); - } - - // Now we can apply new format and re-layout. - _format(style); - - // Continue with remaining part. - final remain = text.substring(lineBreak + 1); - nextLine.insert(0, remain, style); - } - - @override - void retain(int index, int? len, Style? style) { - if (style == null) { - return; - } - final thisLength = length; - - final local = math.min(thisLength - index, len!); - // If index is at newline character then this is a line/block style update. - final isLineFormat = (index + local == thisLength) && local == 1; - - if (isLineFormat) { - assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK), - 'It is not allowed to apply inline attributes to line itself.'); - _format(style); - } else { - // Otherwise forward to children as it's an inline format update. - assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE)); - assert(index + local != thisLength); - super.retain(index, local, style); - } - - final remain = len - local; - if (remain > 0) { - assert(nextLine != null); - nextLine!.retain(0, remain, style); - } - } - - @override - void delete(int index, int? len) { - final local = math.min(length - index, len!); - final isLFDeleted = index + local == length; // Line feed - if (isLFDeleted) { - // Our newline character deleted with all style information. - clearStyle(); - if (local > 1) { - // Exclude newline character from delete range for children. - super.delete(index, local - 1); - } - } else { - super.delete(index, local); - } - - final remaining = len - local; - if (remaining > 0) { - assert(nextLine != null); - nextLine!.delete(0, remaining); - } - - if (isLFDeleted && isNotEmpty) { - // Since we lost our line-break and still have child text nodes those must - // migrate to the next line. - - // nextLine might have been unmounted since last assert so we need to - // check again we still have a line after us. - assert(nextLine != null); - - // Move remaining children in this line to the next line so that all - // attributes of nextLine are preserved. - nextLine!.moveChildToNewParent(this); - moveChildToNewParent(nextLine); - } - - if (isLFDeleted) { - // Now we can remove this line. - final block = parent!; // remember reference before un-linking. - unlink(); - block.adjust(); - } - } - - /// Formats this line. - void _format(Style? newStyle) { - if (newStyle == null || newStyle.isEmpty) { - return; - } - - applyStyle(newStyle); - final blockStyle = newStyle.getBlockExceptHeader(); - if (blockStyle == null) { - return; - } // No block-level changes - - if (parent is Block) { - final parentStyle = (parent as Block).style.getBlocksExceptHeader(); - if (blockStyle.value == null) { - _unwrap(); - } else if (!const MapEquality() - .equals(newStyle.getBlocksExceptHeader(), parentStyle)) { - _unwrap(); - _applyBlockStyles(newStyle); - } // else the same style, no-op. - } else if (blockStyle.value != null) { - // Only wrap with a new block if this is not an unset - _applyBlockStyles(newStyle); - } - } - - void _applyBlockStyles(Style newStyle) { - var block = Block(); - for (final style in newStyle.getBlocksExceptHeader().values) { - block = block..applyAttribute(style); - } - _wrap(block); - block.adjust(); - } - - /// Wraps this line with new parent [block]. - /// - /// This line can not be in a [Block] when this method is called. - void _wrap(Block block) { - assert(parent != null && parent is! Block); - insertAfter(block); - unlink(); - block.add(this); - } - - /// Unwraps this line from it's parent [Block]. - /// - /// This method asserts if current [parent] of this line is not a [Block]. - void _unwrap() { - if (parent is! Block) { - throw ArgumentError('Invalid parent'); - } - final block = parent as Block; - - assert(block.children.contains(this)); - - if (isFirst) { - unlink(); - block.insertBefore(this); - } else if (isLast) { - unlink(); - block.insertAfter(this); - } else { - final before = block.clone() as Block; - block.insertBefore(before); - - var child = block.first as Line; - while (child != this) { - child.unlink(); - before.add(child); - child = block.first as Line; - } - unlink(); - block.insertBefore(this); - } - block.adjust(); - } - - Line _getNextLine(int index) { - assert(index == 0 || (index > 0 && index < length)); - - final line = clone() as Line; - insertAfter(line); - if (index == length - 1) { - return line; - } - - final query = queryChild(index, false); - while (!query.node!.isLast) { - final next = (last as Leaf)..unlink(); - line.addFirst(next); - } - final child = query.node as Leaf; - final cut = child.splitAt(query.offset); - cut?.unlink(); - line.addFirst(cut); - return line; - } - - void _insertSafe(int index, Object data, Style? style) { - assert(index == 0 || (index > 0 && index < length)); - - if (data is String) { - assert(!data.contains('\n')); - if (data.isEmpty) { - return; - } - } - - if (isEmpty) { - final child = Leaf(data); - add(child); - child.format(style); - } else { - final result = queryChild(index, true); - result.node!.insert(result.offset, data, style); - } - } - - /// Returns style for specified text range. - /// - /// Only attributes applied to all characters within this range are - /// included in the result. Inline and line level attributes are - /// handled separately, e.g.: - /// - /// - line attribute X is included in the result only if it exists for - /// every line within this range (partially included lines are counted). - /// - inline attribute X is included in the result only if it exists - /// for every character within this range (line-break characters excluded). - Style collectStyle(int offset, int len) { - final local = math.min(length - offset, len); - var result = Style(); - final excluded = {}; - - void _handle(Style style) { - if (result.isEmpty) { - excluded.addAll(style.values); - } else { - for (final attr in result.values) { - if (!style.containsKey(attr.key)) { - excluded.add(attr); - } - } - } - final remaining = style.removeAll(excluded); - result = result.removeAll(excluded); - result = result.mergeAll(remaining); - } - - final data = queryChild(offset, true); - var node = data.node as Leaf?; - if (node != null) { - result = result.mergeAll(node.style); - var pos = node.length - data.offset; - while (!node!.isLast && pos < local) { - node = node.next as Leaf?; - _handle(node!.style); - pos += node.length; - } - } - - result = result.mergeAll(style); - if (parent is Block) { - final block = parent as Block; - result = result.mergeAll(block.style); - } - - final remaining = len - local; - if (remaining > 0) { - final rest = nextLine!.collectStyle(0, remaining); - _handle(rest); - } - - return result; - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/nodes/line.dart'; diff --git a/lib/models/documents/nodes/node.dart b/lib/models/documents/nodes/node.dart index 6bb0fb97..210c1672 100644 --- a/lib/models/documents/nodes/node.dart +++ b/lib/models/documents/nodes/node.dart @@ -1,131 +1,3 @@ -import 'dart:collection'; - -import '../../quill_delta.dart'; -import '../attribute.dart'; -import '../style.dart'; -import 'container.dart'; -import 'line.dart'; - -/// An abstract node in a document tree. -/// -/// Represents a segment of a Quill document with specified [offset] -/// and [length]. -/// -/// The [offset] property is relative to [parent]. See also [documentOffset] -/// which provides absolute offset of this node within the document. -/// -/// The current parent node is exposed by the [parent] property. -abstract class Node extends LinkedListEntry { - /// Current parent of this node. May be null if this node is not mounted. - Container? parent; - - Style get style => _style; - Style _style = Style(); - - /// Returns `true` if this node is the first node in the [parent] list. - bool get isFirst => list!.first == this; - - /// Returns `true` if this node is the last node in the [parent] list. - bool get isLast => list!.last == this; - - /// Length of this node in characters. - int get length; - - Node clone() => newInstance()..applyStyle(style); - - /// Offset in characters of this node relative to [parent] node. - /// - /// To get offset of this node in the document see [documentOffset]. - int get offset { - var offset = 0; - - if (list == null || isFirst) { - return offset; - } - - var cur = this; - do { - cur = cur.previous!; - offset += cur.length; - } while (!cur.isFirst); - return offset; - } - - /// Offset in characters of this node in the document. - int get documentOffset { - final parentOffset = (parent is! Root) ? parent!.documentOffset : 0; - return parentOffset + offset; - } - - /// Returns `true` if this node contains character at specified [offset] in - /// the document. - bool containsOffset(int offset) { - final o = documentOffset; - return o <= offset && offset < o + length; - } - - void applyAttribute(Attribute attribute) { - _style = _style.merge(attribute); - } - - void applyStyle(Style value) { - _style = _style.mergeAll(value); - } - - void clearStyle() { - _style = Style(); - } - - @override - void insertBefore(Node entry) { - assert(entry.parent == null && parent != null); - entry.parent = parent; - super.insertBefore(entry); - } - - @override - void insertAfter(Node entry) { - assert(entry.parent == null && parent != null); - entry.parent = parent; - super.insertAfter(entry); - } - - @override - void unlink() { - assert(parent != null); - parent = null; - super.unlink(); - } - - void adjust() {/* no-op */} - - /// abstract methods begin - - Node newInstance(); - - String toPlainText(); - - Delta toDelta(); - - void insert(int index, Object data, Style? style); - - void retain(int index, int? len, Style? style); - - void delete(int index, int? len); - - /// abstract methods end -} - -/// Root node of document tree. -class Root extends Container> { - @override - Node newInstance() => Root(); - - @override - Container get defaultChild => Line(); - - @override - Delta toDelta() => children - .map((child) => child.toDelta()) - .fold(Delta(), (a, b) => a.concat(b)); -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/nodes/node.dart'; diff --git a/lib/models/documents/style.dart b/lib/models/documents/style.dart index fade1bb5..a4e06de4 100644 --- a/lib/models/documents/style.dart +++ b/lib/models/documents/style.dart @@ -1,127 +1,3 @@ -import 'package:collection/collection.dart'; -import 'package:quiver/core.dart'; - -import 'attribute.dart'; - -/* Collection of style attributes */ -class Style { - Style() : _attributes = {}; - - Style.attr(this._attributes); - - final Map _attributes; - - static Style fromJson(Map? attributes) { - if (attributes == null) { - return Style(); - } - - final result = attributes.map((key, dynamic value) { - final attr = Attribute.fromKeyValue(key, value); - return MapEntry(key, attr); - }); - return Style.attr(result); - } - - Map? toJson() => _attributes.isEmpty - ? null - : _attributes.map((_, attribute) => - MapEntry(attribute.key, attribute.value)); - - Iterable get keys => _attributes.keys; - - Iterable get values => _attributes.values.sorted( - (a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b)); - - Map get attributes => _attributes; - - bool get isEmpty => _attributes.isEmpty; - - bool get isNotEmpty => _attributes.isNotEmpty; - - bool get isInline => isNotEmpty && values.every((item) => item.isInline); - - bool get isIgnored => - isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE); - - Attribute get single => _attributes.values.single; - - bool containsKey(String key) => _attributes.containsKey(key); - - Attribute? getBlockExceptHeader() { - for (final val in values) { - if (val.isBlockExceptHeader && val.value != null) { - return val; - } - } - for (final val in values) { - if (val.isBlockExceptHeader) { - return val; - } - } - return null; - } - - Map getBlocksExceptHeader() { - final m = {}; - attributes.forEach((key, value) { - if (Attribute.blockKeysExceptHeader.contains(key)) { - m[key] = value; - } - }); - return m; - } - - Style merge(Attribute attribute) { - final merged = Map.from(_attributes); - if (attribute.value == null) { - merged.remove(attribute.key); - } else { - merged[attribute.key] = attribute; - } - return Style.attr(merged); - } - - Style mergeAll(Style other) { - var result = Style.attr(_attributes); - for (final attribute in other.values) { - result = result.merge(attribute); - } - return result; - } - - Style removeAll(Set attributes) { - final merged = Map.from(_attributes); - attributes.map((item) => item.key).forEach(merged.remove); - return Style.attr(merged); - } - - Style put(Attribute attribute) { - final m = Map.from(attributes); - m[attribute.key] = attribute; - return Style.attr(m); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other is! Style) { - return false; - } - final typedOther = other; - const eq = MapEquality(); - return eq.equals(_attributes, typedOther._attributes); - } - - @override - int get hashCode { - final hashes = - _attributes.entries.map((entry) => hash2(entry.key, entry.value)); - return hashObjects(hashes); - } - - @override - String toString() => "{${_attributes.values.join(', ')}}"; -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/documents/style.dart'; diff --git a/lib/models/quill_delta.dart b/lib/models/quill_delta.dart index a0e608be..477fbe33 100644 --- a/lib/models/quill_delta.dart +++ b/lib/models/quill_delta.dart @@ -1,684 +1,3 @@ -// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code -// is governed by a BSD-style license that can be found in the LICENSE file. - -/// Implementation of Quill Delta format in Dart. -library quill_delta; - -import 'dart:math' as math; - -import 'package:collection/collection.dart'; -import 'package:quiver/core.dart'; - -const _attributeEquality = DeepCollectionEquality(); -const _valueEquality = DeepCollectionEquality(); - -/// Decoder function to convert raw `data` object into a user-defined data type. -/// -/// Useful with embedded content. -typedef DataDecoder = Object? Function(Object data); - -/// Default data decoder which simply passes through the original value. -Object? _passThroughDataDecoder(Object? data) => data; - -/// Operation performed on a rich-text document. -class Operation { - Operation._(this.key, this.length, this.data, Map? attributes) - : assert(_validKeys.contains(key), 'Invalid operation key "$key".'), - assert(() { - if (key != Operation.insertKey) return true; - return data is String ? data.length == length : length == 1; - }(), 'Length of insert operation must be equal to the data length.'), - _attributes = - attributes != null ? Map.from(attributes) : null; - - /// Creates operation which deletes [length] of characters. - factory Operation.delete(int length) => - Operation._(Operation.deleteKey, length, '', null); - - /// Creates operation which inserts [text] with optional [attributes]. - factory Operation.insert(dynamic data, [Map? attributes]) => - Operation._(Operation.insertKey, data is String ? data.length : 1, data, - attributes); - - /// Creates operation which retains [length] of characters and optionally - /// applies attributes. - factory Operation.retain(int? length, [Map? attributes]) => - Operation._(Operation.retainKey, length, '', attributes); - - /// Key of insert operations. - static const String insertKey = 'insert'; - - /// Key of delete operations. - static const String deleteKey = 'delete'; - - /// Key of retain operations. - static const String retainKey = 'retain'; - - /// Key of attributes collection. - static const String attributesKey = 'attributes'; - - static const List _validKeys = [insertKey, deleteKey, retainKey]; - - /// Key of this operation, can be "insert", "delete" or "retain". - final String key; - - /// Length of this operation. - final int? length; - - /// Payload of "insert" operation, for other types is set to empty string. - final Object? data; - - /// Rich-text attributes set by this operation, can be `null`. - Map? get attributes => - _attributes == null ? null : Map.from(_attributes!); - final Map? _attributes; - - /// Creates new [Operation] from JSON payload. - /// - /// If `dataDecoder` parameter is not null then it is used to additionally - /// decode the operation's data object. Only applied to insert operations. - static Operation fromJson(Map data, {DataDecoder? dataDecoder}) { - dataDecoder ??= _passThroughDataDecoder; - final map = Map.from(data); - if (map.containsKey(Operation.insertKey)) { - final data = dataDecoder(map[Operation.insertKey]); - final dataLength = data is String ? data.length : 1; - return Operation._( - Operation.insertKey, dataLength, data, map[Operation.attributesKey]); - } else if (map.containsKey(Operation.deleteKey)) { - final int? length = map[Operation.deleteKey]; - return Operation._(Operation.deleteKey, length, '', null); - } else if (map.containsKey(Operation.retainKey)) { - final int? length = map[Operation.retainKey]; - return Operation._( - Operation.retainKey, length, '', map[Operation.attributesKey]); - } - throw ArgumentError.value(data, 'Invalid data for Delta operation.'); - } - - /// Returns JSON-serializable representation of this operation. - Map toJson() { - final json = {key: value}; - if (_attributes != null) json[Operation.attributesKey] = attributes; - return json; - } - - /// Returns value of this operation. - /// - /// For insert operations this returns text, for delete and retain - length. - dynamic get value => (key == Operation.insertKey) ? data : length; - - /// Returns `true` if this is a delete operation. - bool get isDelete => key == Operation.deleteKey; - - /// Returns `true` if this is an insert operation. - bool get isInsert => key == Operation.insertKey; - - /// Returns `true` if this is a retain operation. - bool get isRetain => key == Operation.retainKey; - - /// Returns `true` if this operation has no attributes, e.g. is plain text. - bool get isPlain => _attributes == null || _attributes!.isEmpty; - - /// Returns `true` if this operation sets at least one attribute. - bool get isNotPlain => !isPlain; - - /// Returns `true` is this operation is empty. - /// - /// An operation is considered empty if its [length] is equal to `0`. - bool get isEmpty => length == 0; - - /// Returns `true` is this operation is not empty. - bool get isNotEmpty => length! > 0; - - @override - bool operator ==(other) { - if (identical(this, other)) return true; - if (other is! Operation) return false; - final typedOther = other; - return key == typedOther.key && - length == typedOther.length && - _valueEquality.equals(data, typedOther.data) && - hasSameAttributes(typedOther); - } - - /// Returns `true` if this operation has attribute specified by [name]. - bool hasAttribute(String name) => - isNotPlain && _attributes!.containsKey(name); - - /// Returns `true` if [other] operation has the same attributes as this one. - bool hasSameAttributes(Operation other) { - return _attributeEquality.equals(_attributes, other._attributes); - } - - @override - int get hashCode { - if (_attributes != null && _attributes!.isNotEmpty) { - final attrsHash = - hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value))); - return hash3(key, value, attrsHash); - } - return hash2(key, value); - } - - @override - String toString() { - final attr = attributes == null ? '' : ' + $attributes'; - final text = isInsert - ? (data is String - ? (data as String).replaceAll('\n', '⏎') - : data.toString()) - : '$length'; - return '$key⟨ $text ⟩$attr'; - } -} - -/// Delta represents a document or a modification of a document as a sequence of -/// insert, delete and retain operations. -/// -/// Delta consisting of only "insert" operations is usually referred to as -/// "document delta". When delta includes also "retain" or "delete" operations -/// it is a "change delta". -class Delta { - /// Creates new empty [Delta]. - factory Delta() => Delta._([]); - - Delta._(List operations) : _operations = operations; - - /// Creates new [Delta] from [other]. - factory Delta.from(Delta other) => - Delta._(List.from(other._operations)); - - /// Transforms two attribute sets. - static Map? transformAttributes( - Map? a, Map? b, bool priority) { - if (a == null) return b; - if (b == null) return null; - - if (!priority) return b; - - final result = b.keys.fold>({}, (attributes, key) { - if (!a.containsKey(key)) attributes[key] = b[key]; - return attributes; - }); - - return result.isEmpty ? null : result; - } - - /// Composes two attribute sets. - static Map? composeAttributes( - Map? a, Map? b, - {bool keepNull = false}) { - a ??= const {}; - b ??= const {}; - - final result = Map.from(a)..addAll(b); - final keys = result.keys.toList(growable: false); - - if (!keepNull) { - for (final key in keys) { - if (result[key] == null) result.remove(key); - } - } - - return result.isEmpty ? null : result; - } - - ///get anti-attr result base on base - static Map invertAttributes( - Map? attr, Map? base) { - attr ??= const {}; - base ??= const {}; - - final baseInverted = base.keys.fold({}, (dynamic memo, key) { - if (base![key] != attr![key] && attr.containsKey(key)) { - memo[key] = base[key]; - } - return memo; - }); - - final inverted = - Map.from(attr.keys.fold(baseInverted, (memo, key) { - if (base![key] != attr![key] && !base.containsKey(key)) { - memo[key] = null; - } - return memo; - })); - return inverted; - } - - final List _operations; - - int _modificationCount = 0; - - /// Creates [Delta] from de-serialized JSON representation. - /// - /// If `dataDecoder` parameter is not null then it is used to additionally - /// decode the operation's data object. Only applied to insert operations. - static Delta fromJson(List data, {DataDecoder? dataDecoder}) { - return Delta._(data - .map((op) => Operation.fromJson(op, dataDecoder: dataDecoder)) - .toList()); - } - - /// Returns list of operations in this delta. - List toList() => List.from(_operations); - - /// Returns JSON-serializable version of this delta. - List toJson() => toList().map((operation) => operation.toJson()).toList(); - - /// Returns `true` if this delta is empty. - bool get isEmpty => _operations.isEmpty; - - /// Returns `true` if this delta is not empty. - bool get isNotEmpty => _operations.isNotEmpty; - - /// Returns number of operations in this delta. - int get length => _operations.length; - - /// Returns [Operation] at specified [index] in this delta. - Operation operator [](int index) => _operations[index]; - - /// Returns [Operation] at specified [index] in this delta. - Operation elementAt(int index) => _operations.elementAt(index); - - /// Returns the first [Operation] in this delta. - Operation get first => _operations.first; - - /// Returns the last [Operation] in this delta. - Operation get last => _operations.last; - - @override - bool operator ==(dynamic other) { - if (identical(this, other)) return true; - if (other is! Delta) return false; - final typedOther = other; - const comparator = ListEquality(DefaultEquality()); - return comparator.equals(_operations, typedOther._operations); - } - - @override - int get hashCode => hashObjects(_operations); - - /// Retain [count] of characters from current position. - void retain(int count, [Map? attributes]) { - assert(count >= 0); - if (count == 0) return; // no-op - push(Operation.retain(count, attributes)); - } - - /// Insert [data] at current position. - void insert(dynamic data, [Map? attributes]) { - if (data is String && data.isEmpty) return; // no-op - push(Operation.insert(data, attributes)); - } - - /// Delete [count] characters from current position. - void delete(int count) { - assert(count >= 0); - if (count == 0) return; - push(Operation.delete(count)); - } - - void _mergeWithTail(Operation operation) { - assert(isNotEmpty); - assert(last.key == operation.key); - assert(operation.data is String && last.data is String); - - final length = operation.length! + last.length!; - final lastText = last.data as String; - final opText = operation.data as String; - final resultText = lastText + opText; - final index = _operations.length; - _operations.replaceRange(index - 1, index, [ - Operation._(operation.key, length, resultText, operation.attributes), - ]); - } - - /// Pushes new operation into this delta. - /// - /// Performs compaction by composing [operation] with current tail operation - /// of this delta, when possible. For instance, if current tail is - /// `insert('abc')` and pushed operation is `insert('123')` then existing - /// tail is replaced with `insert('abc123')` - a compound result of the two - /// operations. - void push(Operation operation) { - if (operation.isEmpty) return; - - var index = _operations.length; - final lastOp = _operations.isNotEmpty ? _operations.last : null; - if (lastOp != null) { - if (lastOp.isDelete && operation.isDelete) { - _mergeWithTail(operation); - return; - } - - if (lastOp.isDelete && operation.isInsert) { - index -= 1; // Always insert before deleting - final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null; - if (nLastOp == null) { - _operations.insert(0, operation); - return; - } - } - - if (lastOp.isInsert && operation.isInsert) { - if (lastOp.hasSameAttributes(operation) && - operation.data is String && - lastOp.data is String) { - _mergeWithTail(operation); - return; - } - } - - if (lastOp.isRetain && operation.isRetain) { - if (lastOp.hasSameAttributes(operation)) { - _mergeWithTail(operation); - return; - } - } - } - if (index == _operations.length) { - _operations.add(operation); - } else { - final opAtIndex = _operations.elementAt(index); - _operations.replaceRange(index, index + 1, [operation, opAtIndex]); - } - _modificationCount++; - } - - /// Composes next operation from [thisIter] and [otherIter]. - /// - /// Returns new operation or `null` if operations from [thisIter] and - /// [otherIter] nullify each other. For instance, for the pair `insert('abc')` - /// and `delete(3)` composition result would be empty string. - Operation? _composeOperation( - DeltaIterator thisIter, DeltaIterator otherIter) { - if (otherIter.isNextInsert) return otherIter.next(); - if (thisIter.isNextDelete) return thisIter.next(); - - final length = math.min(thisIter.peekLength(), otherIter.peekLength()); - final thisOp = thisIter.next(length as int); - final otherOp = otherIter.next(length); - assert(thisOp.length == otherOp.length); - - if (otherOp.isRetain) { - final attributes = composeAttributes( - thisOp.attributes, - otherOp.attributes, - keepNull: thisOp.isRetain, - ); - if (thisOp.isRetain) { - return Operation.retain(thisOp.length, attributes); - } else if (thisOp.isInsert) { - return Operation.insert(thisOp.data, attributes); - } else { - throw StateError('Unreachable'); - } - } else { - // otherOp == delete && thisOp in [retain, insert] - assert(otherOp.isDelete); - if (thisOp.isRetain) return otherOp; - assert(thisOp.isInsert); - // otherOp(delete) + thisOp(insert) => null - } - return null; - } - - /// Composes this delta with [other] and returns new [Delta]. - /// - /// It is not required for this and [other] delta to represent a document - /// delta (consisting only of insert operations). - Delta compose(Delta other) { - final result = Delta(); - final thisIter = DeltaIterator(this); - final otherIter = DeltaIterator(other); - - while (thisIter.hasNext || otherIter.hasNext) { - final newOp = _composeOperation(thisIter, otherIter); - if (newOp != null) result.push(newOp); - } - return result..trim(); - } - - /// Transforms next operation from [otherIter] against next operation in - /// [thisIter]. - /// - /// Returns `null` if both operations nullify each other. - Operation? _transformOperation( - DeltaIterator thisIter, DeltaIterator otherIter, bool priority) { - if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) { - return Operation.retain(thisIter.next().length); - } else if (otherIter.isNextInsert) { - return otherIter.next(); - } - - final length = math.min(thisIter.peekLength(), otherIter.peekLength()); - final thisOp = thisIter.next(length as int); - final otherOp = otherIter.next(length); - assert(thisOp.length == otherOp.length); - - // At this point only delete and retain operations are possible. - if (thisOp.isDelete) { - // otherOp is either delete or retain, so they nullify each other. - return null; - } else if (otherOp.isDelete) { - return otherOp; - } else { - // Retain otherOp which is either retain or insert. - return Operation.retain( - length, - transformAttributes(thisOp.attributes, otherOp.attributes, priority), - ); - } - } - - /// Transforms [other] delta against operations in this delta. - Delta transform(Delta other, bool priority) { - final result = Delta(); - final thisIter = DeltaIterator(this); - final otherIter = DeltaIterator(other); - - while (thisIter.hasNext || otherIter.hasNext) { - final newOp = _transformOperation(thisIter, otherIter, priority); - if (newOp != null) result.push(newOp); - } - return result..trim(); - } - - /// Removes trailing retain operation with empty attributes, if present. - void trim() { - if (isNotEmpty) { - final last = _operations.last; - if (last.isRetain && last.isPlain) _operations.removeLast(); - } - } - - /// Concatenates [other] with this delta and returns the result. - Delta concat(Delta other) { - final result = Delta.from(this); - if (other.isNotEmpty) { - // In case first operation of other can be merged with last operation in - // our list. - result.push(other._operations.first); - result._operations.addAll(other._operations.sublist(1)); - } - return result; - } - - /// Inverts this delta against [base]. - /// - /// Returns new delta which negates effect of this delta when applied to - /// [base]. This is an equivalent of "undo" operation on deltas. - Delta invert(Delta base) { - final inverted = Delta(); - if (base.isEmpty) return inverted; - - var baseIndex = 0; - for (final op in _operations) { - if (op.isInsert) { - inverted.delete(op.length!); - } else if (op.isRetain && op.isPlain) { - inverted.retain(op.length!); - baseIndex += op.length!; - } else if (op.isDelete || (op.isRetain && op.isNotPlain)) { - final length = op.length!; - final sliceDelta = base.slice(baseIndex, baseIndex + length); - sliceDelta.toList().forEach((baseOp) { - if (op.isDelete) { - inverted.push(baseOp); - } else if (op.isRetain && op.isNotPlain) { - final invertAttr = - invertAttributes(op.attributes, baseOp.attributes); - inverted.retain( - baseOp.length!, invertAttr.isEmpty ? null : invertAttr); - } - }); - baseIndex += length; - } else { - throw StateError('Unreachable'); - } - } - inverted.trim(); - return inverted; - } - - /// Returns slice of this delta from [start] index (inclusive) to [end] - /// (exclusive). - Delta slice(int start, [int? end]) { - final delta = Delta(); - var index = 0; - final opIterator = DeltaIterator(this); - - final actualEnd = end ?? double.infinity; - - while (index < actualEnd && opIterator.hasNext) { - Operation op; - if (index < start) { - op = opIterator.next(start - index); - } else { - op = opIterator.next(actualEnd - index as int); - delta.push(op); - } - index += op.length!; - } - return delta; - } - - /// Transforms [index] against this delta. - /// - /// Any "delete" operation before specified [index] shifts it backward, as - /// well as any "insert" operation shifts it forward. - /// - /// The [force] argument is used to resolve scenarios when there is an - /// insert operation at the same position as [index]. If [force] is set to - /// `true` (default) then position is forced to shift forward, otherwise - /// position stays at the same index. In other words setting [force] to - /// `false` gives higher priority to the transformed position. - /// - /// Useful to adjust caret or selection positions. - int transformPosition(int index, {bool force = true}) { - final iter = DeltaIterator(this); - var offset = 0; - while (iter.hasNext && offset <= index) { - final op = iter.next(); - if (op.isDelete) { - index -= math.min(op.length!, index - offset); - continue; - } else if (op.isInsert && (offset < index || force)) { - index += op.length!; - } - offset += op.length!; - } - return index; - } - - @override - String toString() => _operations.join('\n'); -} - -/// Specialized iterator for [Delta]s. -class DeltaIterator { - DeltaIterator(this.delta) : _modificationCount = delta._modificationCount; - - final Delta delta; - final int _modificationCount; - int _index = 0; - num _offset = 0; - - bool get isNextInsert => nextOperationKey == Operation.insertKey; - - bool get isNextDelete => nextOperationKey == Operation.deleteKey; - - bool get isNextRetain => nextOperationKey == Operation.retainKey; - - String? get nextOperationKey { - if (_index < delta.length) { - return delta.elementAt(_index).key; - } else { - return null; - } - } - - bool get hasNext => peekLength() < double.infinity; - - /// Returns length of next operation without consuming it. - /// - /// Returns [double.infinity] if there is no more operations left to iterate. - num peekLength() { - if (_index < delta.length) { - final operation = delta._operations[_index]; - return operation.length! - _offset; - } - return double.infinity; - } - - /// Consumes and returns next operation. - /// - /// Optional [length] specifies maximum length of operation to return. Note - /// that actual length of returned operation may be less than specified value. - Operation next([int length = 4294967296]) { - if (_modificationCount != delta._modificationCount) { - throw ConcurrentModificationError(delta); - } - - if (_index < delta.length) { - final op = delta.elementAt(_index); - final opKey = op.key; - final opAttributes = op.attributes; - final _currentOffset = _offset; - final actualLength = math.min(op.length! - _currentOffset, length); - if (actualLength == op.length! - _currentOffset) { - _index++; - _offset = 0; - } else { - _offset += actualLength; - } - final opData = op.isInsert && op.data is String - ? (op.data as String).substring( - _currentOffset as int, _currentOffset + (actualLength as int)) - : op.data; - final opIsNotEmpty = - opData is String ? opData.isNotEmpty : true; // embeds are never empty - final opLength = opData is String ? opData.length : 1; - final opActualLength = opIsNotEmpty ? opLength : actualLength as int; - return Operation._(opKey, opActualLength, opData, opAttributes); - } - return Operation.retain(length); - } - - /// Skips [length] characters in source delta. - /// - /// Returns last skipped operation, or `null` if there was nothing to skip. - Operation? skip(int length) { - var skipped = 0; - Operation? op; - while (skipped < length && hasNext) { - final opLength = peekLength(); - final skip = math.min(length - skipped, opLength); - op = next(skip as int); - skipped += op.length!; - } - return op; - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/quill_delta.dart'; diff --git a/lib/models/rules/delete.dart b/lib/models/rules/delete.dart index e6682f94..65a27b0e 100644 --- a/lib/models/rules/delete.dart +++ b/lib/models/rules/delete.dart @@ -1,124 +1,3 @@ -import '../documents/attribute.dart'; -import '../quill_delta.dart'; -import 'rule.dart'; - -abstract class DeleteRule extends Rule { - const DeleteRule(); - - @override - RuleType get type => RuleType.DELETE; - - @override - void validateArgs(int? len, Object? data, Attribute? attribute) { - assert(len != null); - assert(data == null); - assert(attribute == null); - } -} - -class CatchAllDeleteRule extends DeleteRule { - const CatchAllDeleteRule(); - - @override - Delta applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - return Delta() - ..retain(index) - ..delete(len!); - } -} - -class PreserveLineStyleOnMergeRule extends DeleteRule { - const PreserveLineStyleOnMergeRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - final itr = DeltaIterator(document)..skip(index); - var op = itr.next(1); - if (op.data != '\n') { - return null; - } - - final isNotPlain = op.isNotPlain; - final attrs = op.attributes; - - itr.skip(len! - 1); - final delta = Delta() - ..retain(index) - ..delete(len); - - while (itr.hasNext) { - op = itr.next(); - final text = op.data is String ? (op.data as String?)! : ''; - final lineBreak = text.indexOf('\n'); - if (lineBreak == -1) { - delta.retain(op.length!); - continue; - } - - var attributes = op.attributes == null - ? null - : op.attributes!.map( - (key, dynamic value) => MapEntry(key, null)); - - if (isNotPlain) { - attributes ??= {}; - attributes.addAll(attrs!); - } - delta..retain(lineBreak)..retain(1, attributes); - break; - } - return delta; - } -} - -class EnsureEmbedLineRule extends DeleteRule { - const EnsureEmbedLineRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - final itr = DeltaIterator(document); - - var op = itr.skip(index); - int? indexDelta = 0, lengthDelta = 0, remain = len; - var embedFound = op != null && op.data is! String; - final hasLineBreakBefore = - !embedFound && (op == null || (op.data as String).endsWith('\n')); - if (embedFound) { - var candidate = itr.next(1); - if (remain != null) { - remain--; - if (candidate.data == '\n') { - indexDelta++; - lengthDelta--; - - candidate = itr.next(1); - remain--; - if (candidate.data == '\n') { - lengthDelta++; - } - } - } - } - - op = itr.skip(remain!); - if (op != null && - (op.data is String ? op.data as String? : '')!.endsWith('\n')) { - final candidate = itr.next(1); - if (candidate.data is! String && !hasLineBreakBefore) { - embedFound = true; - lengthDelta--; - } - } - - if (!embedFound) { - return null; - } - - return Delta() - ..retain(index + indexDelta) - ..delete(len! + lengthDelta); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/rules/delete.dart'; diff --git a/lib/models/rules/format.dart b/lib/models/rules/format.dart index be201925..e6251d03 100644 --- a/lib/models/rules/format.dart +++ b/lib/models/rules/format.dart @@ -1,132 +1,3 @@ -import '../documents/attribute.dart'; -import '../quill_delta.dart'; -import 'rule.dart'; - -abstract class FormatRule extends Rule { - const FormatRule(); - - @override - RuleType get type => RuleType.FORMAT; - - @override - void validateArgs(int? len, Object? data, Attribute? attribute) { - assert(len != null); - assert(data == null); - assert(attribute != null); - } -} - -class ResolveLineFormatRule extends FormatRule { - const ResolveLineFormatRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (attribute!.scope != AttributeScope.BLOCK) { - return null; - } - - var delta = Delta()..retain(index); - final itr = DeltaIterator(document)..skip(index); - Operation op; - for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { - op = itr.next(len - cur); - if (op.data is! String || !(op.data as String).contains('\n')) { - delta.retain(op.length!); - continue; - } - final text = op.data as String; - final tmp = Delta(); - var offset = 0; - - for (var lineBreak = text.indexOf('\n'); - lineBreak >= 0; - lineBreak = text.indexOf('\n', offset)) { - tmp..retain(lineBreak - offset)..retain(1, attribute.toJson()); - offset = lineBreak + 1; - } - tmp.retain(text.length - offset); - delta = delta.concat(tmp); - } - - while (itr.hasNext) { - op = itr.next(); - final text = op.data is String ? (op.data as String?)! : ''; - final lineBreak = text.indexOf('\n'); - if (lineBreak < 0) { - delta.retain(op.length!); - continue; - } - delta..retain(lineBreak)..retain(1, attribute.toJson()); - break; - } - return delta; - } -} - -class FormatLinkAtCaretPositionRule extends FormatRule { - const FormatLinkAtCaretPositionRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (attribute!.key != Attribute.link.key || len! > 0) { - return null; - } - - final delta = Delta(); - final itr = DeltaIterator(document); - final before = itr.skip(index), after = itr.next(); - int? beg = index, retain = 0; - if (before != null && before.hasAttribute(attribute.key)) { - beg -= before.length!; - retain = before.length; - } - if (after.hasAttribute(attribute.key)) { - if (retain != null) retain += after.length!; - } - if (retain == 0) { - return null; - } - - delta..retain(beg)..retain(retain!, attribute.toJson()); - return delta; - } -} - -class ResolveInlineFormatRule extends FormatRule { - const ResolveInlineFormatRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (attribute!.scope != AttributeScope.INLINE) { - return null; - } - - final delta = Delta()..retain(index); - final itr = DeltaIterator(document)..skip(index); - - Operation op; - for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { - op = itr.next(len - cur); - final text = op.data is String ? (op.data as String?)! : ''; - var lineBreak = text.indexOf('\n'); - if (lineBreak < 0) { - delta.retain(op.length!, attribute.toJson()); - continue; - } - var pos = 0; - while (lineBreak >= 0) { - delta..retain(lineBreak - pos, attribute.toJson())..retain(1); - pos = lineBreak + 1; - lineBreak = text.indexOf('\n', pos); - } - if (pos < op.length!) { - delta.retain(op.length! - pos, attribute.toJson()); - } - } - - return delta; - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/rules/format.dart'; diff --git a/lib/models/rules/insert.dart b/lib/models/rules/insert.dart index 5801a10e..4dfe6ab7 100644 --- a/lib/models/rules/insert.dart +++ b/lib/models/rules/insert.dart @@ -1,413 +1,3 @@ -import 'package:tuple/tuple.dart'; - -import '../documents/attribute.dart'; -import '../documents/style.dart'; -import '../quill_delta.dart'; -import 'rule.dart'; - -abstract class InsertRule extends Rule { - const InsertRule(); - - @override - RuleType get type => RuleType.INSERT; - - @override - void validateArgs(int? len, Object? data, Attribute? attribute) { - assert(data != null); - assert(attribute == null); - } -} - -class PreserveLineStyleOnSplitRule extends InsertRule { - const PreserveLineStyleOnSplitRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String || data != '\n') { - return null; - } - - final itr = DeltaIterator(document); - final before = itr.skip(index); - if (before == null || - before.data is! String || - (before.data as String).endsWith('\n')) { - return null; - } - final after = itr.next(); - if (after.data is! String || (after.data as String).startsWith('\n')) { - return null; - } - - final text = after.data as String; - - final delta = Delta()..retain(index + (len ?? 0)); - if (text.contains('\n')) { - assert(after.isPlain); - delta.insert('\n'); - return delta; - } - final nextNewLine = _getNextNewLine(itr); - final attributes = nextNewLine.item1?.attributes; - - return delta..insert('\n', attributes); - } -} - -/// Preserves block style when user inserts text containing newlines. -/// -/// This rule handles: -/// -/// * inserting a new line in a block -/// * pasting text containing multiple lines of text in a block -/// -/// This rule may also be activated for changes triggered by auto-correct. -class PreserveBlockStyleOnInsertRule extends InsertRule { - const PreserveBlockStyleOnInsertRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String || !data.contains('\n')) { - // Only interested in text containing at least one newline character. - return null; - } - - final itr = DeltaIterator(document)..skip(index); - - // Look for the next newline. - final nextNewLine = _getNextNewLine(itr); - final lineStyle = - Style.fromJson(nextNewLine.item1?.attributes ?? {}); - - final blockStyle = lineStyle.getBlocksExceptHeader(); - // Are we currently in a block? If not then ignore. - if (blockStyle.isEmpty) { - return null; - } - - Map? resetStyle; - // If current line had heading style applied to it we'll need to move this - // style to the newly inserted line before it and reset style of the - // original line. - if (lineStyle.containsKey(Attribute.header.key)) { - resetStyle = Attribute.header.toJson(); - } - - // Go over each inserted line and ensure block style is applied. - final lines = data.split('\n'); - final delta = Delta()..retain(index + (len ?? 0)); - for (var i = 0; i < lines.length; i++) { - final line = lines[i]; - if (line.isNotEmpty) { - delta.insert(line); - } - if (i == 0) { - // The first line should inherit the lineStyle entirely. - delta.insert('\n', lineStyle.toJson()); - } else if (i < lines.length - 1) { - // we don't want to insert a newline after the last chunk of text, so -1 - delta.insert('\n', blockStyle); - } - } - - // Reset style of the original newline character if needed. - if (resetStyle != null) { - delta - ..retain(nextNewLine.item2!) - ..retain((nextNewLine.item1!.data as String).indexOf('\n')) - ..retain(1, resetStyle); - } - - return delta; - } -} - -/// Heuristic rule to exit current block when user inserts two consecutive -/// newlines. -/// -/// This rule is only applied when the cursor is on the last line of a block. -/// When the cursor is in the middle of a block we allow adding empty lines -/// and preserving the block's style. -class AutoExitBlockRule extends InsertRule { - const AutoExitBlockRule(); - - bool _isEmptyLine(Operation? before, Operation? after) { - if (before == null) { - return true; - } - return before.data is String && - (before.data as String).endsWith('\n') && - after!.data is String && - (after.data as String).startsWith('\n'); - } - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String || data != '\n') { - return null; - } - - final itr = DeltaIterator(document); - final prev = itr.skip(index), cur = itr.next(); - final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader(); - // We are not in a block, ignore. - if (cur.isPlain || blockStyle == null) { - return null; - } - // We are not on an empty line, ignore. - if (!_isEmptyLine(prev, cur)) { - return null; - } - - // We are on an empty line. Now we need to determine if we are on the - // last line of a block. - // First check if `cur` length is greater than 1, this would indicate - // that it contains multiple newline characters which share the same style. - // This would mean we are not on the last line yet. - // `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline - if ((cur.value as String).length > 1) { - // We are not on the last line of this block, ignore. - return null; - } - - // Keep looking for the next newline character to see if it shares the same - // block style as `cur`. - final nextNewLine = _getNextNewLine(itr); - if (nextNewLine.item1 != null && - nextNewLine.item1!.attributes != null && - Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() == - blockStyle) { - // We are not at the end of this block, ignore. - return null; - } - - // Here we now know that the line after `cur` is not in the same block - // therefore we can exit this block. - final attributes = cur.attributes ?? {}; - final k = attributes.keys - .firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k)); - attributes[k] = null; - // retain(1) should be '\n', set it with no attribute - return Delta()..retain(index + (len ?? 0))..retain(1, attributes); - } -} - -class ResetLineFormatOnNewLineRule extends InsertRule { - const ResetLineFormatOnNewLineRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String || data != '\n') { - return null; - } - - final itr = DeltaIterator(document)..skip(index); - final cur = itr.next(); - if (cur.data is! String || !(cur.data as String).startsWith('\n')) { - return null; - } - - Map? resetStyle; - if (cur.attributes != null && - cur.attributes!.containsKey(Attribute.header.key)) { - resetStyle = Attribute.header.toJson(); - } - return Delta() - ..retain(index + (len ?? 0)) - ..insert('\n', cur.attributes) - ..retain(1, resetStyle) - ..trim(); - } -} - -class InsertEmbedsRule extends InsertRule { - const InsertEmbedsRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is String) { - return null; - } - - final delta = Delta()..retain(index + (len ?? 0)); - final itr = DeltaIterator(document); - final prev = itr.skip(index), cur = itr.next(); - - final textBefore = prev?.data is String ? prev!.data as String? : ''; - final textAfter = cur.data is String ? (cur.data as String?)! : ''; - - final isNewlineBefore = prev == null || textBefore!.endsWith('\n'); - final isNewlineAfter = textAfter.startsWith('\n'); - - if (isNewlineBefore && isNewlineAfter) { - return delta..insert(data); - } - - Map? lineStyle; - if (textAfter.contains('\n')) { - lineStyle = cur.attributes; - } else { - while (itr.hasNext) { - final op = itr.next(); - if ((op.data is String ? op.data as String? : '')!.contains('\n')) { - lineStyle = op.attributes; - break; - } - } - } - - if (!isNewlineBefore) { - delta.insert('\n', lineStyle); - } - delta.insert(data); - if (!isNewlineAfter) { - delta.insert('\n'); - } - return delta; - } -} - -class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { - const ForceNewlineForInsertsAroundEmbedRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String) { - return null; - } - - final text = data; - final itr = DeltaIterator(document); - final prev = itr.skip(index); - final cur = itr.next(); - final cursorBeforeEmbed = cur.data is! String; - final cursorAfterEmbed = prev != null && prev.data is! String; - - if (!cursorBeforeEmbed && !cursorAfterEmbed) { - return null; - } - final delta = Delta()..retain(index + (len ?? 0)); - if (cursorBeforeEmbed && !text.endsWith('\n')) { - return delta..insert(text)..insert('\n'); - } - if (cursorAfterEmbed && !text.startsWith('\n')) { - return delta..insert('\n')..insert(text); - } - return delta..insert(text); - } -} - -class AutoFormatLinksRule extends InsertRule { - const AutoFormatLinksRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String || data != ' ') { - return null; - } - - final itr = DeltaIterator(document); - final prev = itr.skip(index); - if (prev == null || prev.data is! String) { - return null; - } - - try { - final cand = (prev.data as String).split('\n').last.split(' ').last; - final link = Uri.parse(cand); - if (!['https', 'http'].contains(link.scheme)) { - return null; - } - final attributes = prev.attributes ?? {}; - - if (attributes.containsKey(Attribute.link.key)) { - return null; - } - - attributes.addAll(LinkAttribute(link.toString()).toJson()); - return Delta() - ..retain(index + (len ?? 0) - cand.length) - ..retain(cand.length, attributes) - ..insert(data, prev.attributes); - } on FormatException { - return null; - } - } -} - -class PreserveInlineStylesRule extends InsertRule { - const PreserveInlineStylesRule(); - - @override - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - if (data is! String || data.contains('\n')) { - return null; - } - - final itr = DeltaIterator(document); - final prev = itr.skip(index); - if (prev == null || - prev.data is! String || - (prev.data as String).contains('\n')) { - return null; - } - - final attributes = prev.attributes; - final text = data; - if (attributes == null || !attributes.containsKey(Attribute.link.key)) { - return Delta() - ..retain(index + (len ?? 0)) - ..insert(text, attributes); - } - - attributes.remove(Attribute.link.key); - final delta = Delta() - ..retain(index + (len ?? 0)) - ..insert(text, attributes.isEmpty ? null : attributes); - final next = itr.next(); - - final nextAttributes = next.attributes ?? const {}; - if (!nextAttributes.containsKey(Attribute.link.key)) { - return delta; - } - if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) { - return Delta() - ..retain(index + (len ?? 0)) - ..insert(text, attributes); - } - return delta; - } -} - -class CatchAllInsertRule extends InsertRule { - const CatchAllInsertRule(); - - @override - Delta applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - return Delta() - ..retain(index + (len ?? 0)) - ..insert(data); - } -} - -Tuple2 _getNextNewLine(DeltaIterator iterator) { - Operation op; - for (var skipped = 0; iterator.hasNext; skipped += op.length!) { - op = iterator.next(); - final lineBreak = - (op.data is String ? op.data as String? : '')!.indexOf('\n'); - if (lineBreak >= 0) { - return Tuple2(op, skipped); - } - } - return const Tuple2(null, null); -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/rules/insert.dart'; diff --git a/lib/models/rules/rule.dart b/lib/models/rules/rule.dart index 042f1aaa..11026f46 100644 --- a/lib/models/rules/rule.dart +++ b/lib/models/rules/rule.dart @@ -1,77 +1,3 @@ -import '../documents/attribute.dart'; -import '../documents/document.dart'; -import '../quill_delta.dart'; -import 'delete.dart'; -import 'format.dart'; -import 'insert.dart'; - -enum RuleType { INSERT, DELETE, FORMAT } - -abstract class Rule { - const Rule(); - - Delta? apply(Delta document, int index, - {int? len, Object? data, Attribute? attribute}) { - validateArgs(len, data, attribute); - return applyRule(document, index, - len: len, data: data, attribute: attribute); - } - - void validateArgs(int? len, Object? data, Attribute? attribute); - - Delta? applyRule(Delta document, int index, - {int? len, Object? data, Attribute? attribute}); - - RuleType get type; -} - -class Rules { - Rules(this._rules); - - List _customRules = []; - - final List _rules; - static final Rules _instance = Rules([ - const FormatLinkAtCaretPositionRule(), - const ResolveLineFormatRule(), - const ResolveInlineFormatRule(), - const InsertEmbedsRule(), - const ForceNewlineForInsertsAroundEmbedRule(), - const AutoExitBlockRule(), - const PreserveBlockStyleOnInsertRule(), - const PreserveLineStyleOnSplitRule(), - const ResetLineFormatOnNewLineRule(), - const AutoFormatLinksRule(), - const PreserveInlineStylesRule(), - const CatchAllInsertRule(), - const EnsureEmbedLineRule(), - const PreserveLineStyleOnMergeRule(), - const CatchAllDeleteRule(), - ]); - - 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 _customRules + _rules) { - if (rule.type != ruleType) { - continue; - } - try { - final result = rule.apply(delta, index, - len: len, data: data, attribute: attribute); - if (result != null) { - return result..trim(); - } - } catch (e) { - rethrow; - } - } - throw 'Apply rules failed'; - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/models/rules/rule.dart'; diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart new file mode 100644 index 00000000..1b9043b9 --- /dev/null +++ b/lib/src/models/documents/attribute.dart @@ -0,0 +1,292 @@ +import 'dart:collection'; + +import 'package:quiver/core.dart'; + +enum AttributeScope { + INLINE, // refer to https://quilljs.com/docs/formats/#inline + BLOCK, // refer to https://quilljs.com/docs/formats/#block + EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds + IGNORE, // attributes that can be ignored +} + +class Attribute { + Attribute(this.key, this.scope, this.value); + + final String key; + final AttributeScope scope; + final T value; + + static final Map _registry = LinkedHashMap.of({ + Attribute.bold.key: Attribute.bold, + Attribute.italic.key: Attribute.italic, + Attribute.underline.key: Attribute.underline, + Attribute.strikeThrough.key: Attribute.strikeThrough, + Attribute.font.key: Attribute.font, + Attribute.size.key: Attribute.size, + Attribute.link.key: Attribute.link, + Attribute.color.key: Attribute.color, + Attribute.background.key: Attribute.background, + Attribute.placeholder.key: Attribute.placeholder, + Attribute.header.key: Attribute.header, + Attribute.align.key: Attribute.align, + Attribute.list.key: Attribute.list, + Attribute.codeBlock.key: Attribute.codeBlock, + Attribute.blockQuote.key: Attribute.blockQuote, + Attribute.indent.key: Attribute.indent, + Attribute.width.key: Attribute.width, + Attribute.height.key: Attribute.height, + Attribute.style.key: Attribute.style, + Attribute.token.key: Attribute.token, + }); + + static final BoldAttribute bold = BoldAttribute(); + + static final ItalicAttribute italic = ItalicAttribute(); + + static final UnderlineAttribute underline = UnderlineAttribute(); + + static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); + + static final FontAttribute font = FontAttribute(null); + + static final SizeAttribute size = SizeAttribute(null); + + static final LinkAttribute link = LinkAttribute(null); + + static final ColorAttribute color = ColorAttribute(null); + + static final BackgroundAttribute background = BackgroundAttribute(null); + + static final PlaceholderAttribute placeholder = PlaceholderAttribute(); + + static final HeaderAttribute header = HeaderAttribute(); + + static final IndentAttribute indent = IndentAttribute(); + + static final AlignAttribute align = AlignAttribute(null); + + static final ListAttribute list = ListAttribute(null); + + static final CodeBlockAttribute codeBlock = CodeBlockAttribute(); + + static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute(); + + static final WidthAttribute width = WidthAttribute(null); + + static final HeightAttribute height = HeightAttribute(null); + + static final StyleAttribute style = StyleAttribute(null); + + static final TokenAttribute token = TokenAttribute(''); + + static final Set inlineKeys = { + Attribute.bold.key, + Attribute.italic.key, + Attribute.underline.key, + Attribute.strikeThrough.key, + Attribute.link.key, + Attribute.color.key, + Attribute.background.key, + Attribute.placeholder.key, + }; + + static final Set blockKeys = LinkedHashSet.of({ + Attribute.header.key, + Attribute.align.key, + Attribute.list.key, + Attribute.codeBlock.key, + Attribute.blockQuote.key, + Attribute.indent.key, + }); + + static final Set blockKeysExceptHeader = LinkedHashSet.of({ + Attribute.list.key, + Attribute.align.key, + Attribute.codeBlock.key, + Attribute.blockQuote.key, + Attribute.indent.key, + }); + + static Attribute get h1 => HeaderAttribute(level: 1); + + static Attribute get h2 => HeaderAttribute(level: 2); + + static Attribute get h3 => HeaderAttribute(level: 3); + + // "attributes":{"align":"left"} + static Attribute get leftAlignment => AlignAttribute('left'); + + // "attributes":{"align":"center"} + static Attribute get centerAlignment => AlignAttribute('center'); + + // "attributes":{"align":"right"} + static Attribute get rightAlignment => AlignAttribute('right'); + + // "attributes":{"align":"justify"} + static Attribute get justifyAlignment => AlignAttribute('justify'); + + // "attributes":{"list":"bullet"} + static Attribute get ul => ListAttribute('bullet'); + + // "attributes":{"list":"ordered"} + static Attribute get ol => ListAttribute('ordered'); + + // "attributes":{"list":"checked"} + static Attribute get checked => ListAttribute('checked'); + + // "attributes":{"list":"unchecked"} + static Attribute get unchecked => ListAttribute('unchecked'); + + // "attributes":{"indent":1"} + static Attribute get indentL1 => IndentAttribute(level: 1); + + // "attributes":{"indent":2"} + static Attribute get indentL2 => IndentAttribute(level: 2); + + // "attributes":{"indent":3"} + static Attribute get indentL3 => IndentAttribute(level: 3); + + static Attribute getIndentLevel(int? level) { + if (level == 1) { + return indentL1; + } + if (level == 2) { + return indentL2; + } + if (level == 3) { + return indentL3; + } + return IndentAttribute(level: level); + } + + bool get isInline => scope == AttributeScope.INLINE; + + bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key); + + Map toJson() => {key: value}; + + static Attribute fromKeyValue(String key, dynamic value) { + if (!_registry.containsKey(key)) { + throw ArgumentError.value(key, 'key "$key" not found.'); + } + final origin = _registry[key]!; + final attribute = clone(origin, value); + return attribute; + } + + static int getRegistryOrder(Attribute attribute) { + var order = 0; + for (final attr in _registry.values) { + if (attr.key == attribute.key) { + break; + } + order++; + } + + return order; + } + + static Attribute clone(Attribute origin, dynamic value) { + return Attribute(origin.key, origin.scope, value); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! Attribute) return false; + final typedOther = other; + return key == typedOther.key && + scope == typedOther.scope && + value == typedOther.value; + } + + @override + int get hashCode => hash3(key, scope, value); + + @override + String toString() { + return 'Attribute{key: $key, scope: $scope, value: $value}'; + } +} + +class BoldAttribute extends Attribute { + BoldAttribute() : super('bold', AttributeScope.INLINE, true); +} + +class ItalicAttribute extends Attribute { + ItalicAttribute() : super('italic', AttributeScope.INLINE, true); +} + +class UnderlineAttribute extends Attribute { + UnderlineAttribute() : super('underline', AttributeScope.INLINE, true); +} + +class StrikeThroughAttribute extends Attribute { + StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true); +} + +class FontAttribute extends Attribute { + FontAttribute(String? val) : super('font', AttributeScope.INLINE, val); +} + +class SizeAttribute extends Attribute { + SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val); +} + +class LinkAttribute extends Attribute { + LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val); +} + +class ColorAttribute extends Attribute { + ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val); +} + +class BackgroundAttribute extends Attribute { + BackgroundAttribute(String? val) + : super('background', AttributeScope.INLINE, val); +} + +/// This is custom attribute for hint +class PlaceholderAttribute extends Attribute { + PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true); +} + +class HeaderAttribute extends Attribute { + HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level); +} + +class IndentAttribute extends Attribute { + IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level); +} + +class AlignAttribute extends Attribute { + AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val); +} + +class ListAttribute extends Attribute { + ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val); +} + +class CodeBlockAttribute extends Attribute { + CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true); +} + +class BlockQuoteAttribute extends Attribute { + BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true); +} + +class WidthAttribute extends Attribute { + WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val); +} + +class HeightAttribute extends Attribute { + HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val); +} + +class StyleAttribute extends Attribute { + StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val); +} + +class TokenAttribute extends Attribute { + TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); +} diff --git a/lib/src/models/documents/document.dart b/lib/src/models/documents/document.dart new file mode 100644 index 00000000..a26d885a --- /dev/null +++ b/lib/src/models/documents/document.dart @@ -0,0 +1,290 @@ +import 'dart:async'; + +import 'package:tuple/tuple.dart'; + +import '../quill_delta.dart'; +import '../rules/rule.dart'; +import 'attribute.dart'; +import 'history.dart'; +import 'nodes/block.dart'; +import 'nodes/container.dart'; +import 'nodes/embed.dart'; +import 'nodes/line.dart'; +import 'nodes/node.dart'; +import 'style.dart'; + +/// The rich text document +class Document { + Document() : _delta = Delta()..insert('\n') { + _loadDocument(_delta); + } + + Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) { + _loadDocument(_delta); + } + + Document.fromDelta(Delta delta) : _delta = delta { + _loadDocument(delta); + } + + /// The root node of the document tree + final Root _root = Root(); + + Root get root => _root; + + int get length => _root.length; + + Delta _delta; + + Delta toDelta() => Delta.from(_delta); + + final Rules _rules = Rules.getInstance(); + + void setCustomRules(List customRules) { + _rules.setCustomRules(customRules); + } + + final StreamController> _observer = + StreamController.broadcast(); + + final History _history = History(); + + Stream> get changes => _observer.stream; + + 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) { + data = data.toJson(); + } else if ((data as String).isEmpty) { + return Delta(); + } + + final delta = _rules.apply(RuleType.INSERT, this, index, + data: data, len: replaceLength); + compose(delta, ChangeSource.LOCAL, + autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); + return delta; + } + + Delta delete(int index, int len) { + assert(index >= 0 && len > 0); + final delta = _rules.apply(RuleType.DELETE, this, index, len: len); + if (delta.isNotEmpty) { + compose(delta, ChangeSource.LOCAL); + } + return delta; + } + + Delta replace(int index, int len, Object? data, + {bool autoAppendNewlineAfterImage = true}) { + assert(index >= 0); + assert(data is String || data is Embeddable); + + final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true; + + assert(dataIsNotEmpty || len > 0); + + 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, data, + replaceLength: len, + autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); + } + + if (len > 0) { + final deleteDelta = delete(index, len); + delta = delta.compose(deleteDelta); + } + + return delta; + } + + Delta format(int index, int len, Attribute? attribute) { + assert(index >= 0 && len >= 0 && attribute != null); + + var delta = Delta(); + + final formatDelta = _rules.apply(RuleType.FORMAT, this, index, + len: len, attribute: attribute); + if (formatDelta.isNotEmpty) { + compose(formatDelta, ChangeSource.LOCAL); + delta = delta.compose(formatDelta); + } + + return delta; + } + + Style collectStyle(int index, int len) { + final res = queryChild(index); + return (res.node as Line).collectStyle(res.offset, len); + } + + ChildQuery queryChild(int offset) { + final res = _root.queryChild(offset, true); + if (res.node is Line) { + return res; + } + final block = res.node as Block; + return block.queryChild(res.offset, true); + } + + void compose(Delta delta, ChangeSource changeSource, + {bool autoAppendNewlineAfterImage = true}) { + assert(!_observer.isClosed); + delta.trim(); + assert(delta.isNotEmpty); + + var offset = 0; + delta = _transform(delta, + autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); + final originalDelta = toDelta(); + for (final op in delta.toList()) { + final style = + op.attributes != null ? Style.fromJson(op.attributes) : null; + + if (op.isInsert) { + _root.insert(offset, _normalize(op.data), style); + } else if (op.isDelete) { + _root.delete(offset, op.length); + } else if (op.attributes != null) { + _root.retain(offset, op.length, style); + } + + if (!op.isDelete) { + offset += op.length!; + } + } + try { + _delta = _delta.compose(delta); + } catch (e) { + throw '_delta compose failed'; + } + + if (_delta != _root.toDelta()) { + throw 'Compose failed'; + } + final change = Tuple3(originalDelta, delta, changeSource); + _observer.add(change); + _history.handleDocChange(change); + } + + Tuple2 undo() { + return _history.undo(this); + } + + Tuple2 redo() { + return _history.redo(this); + } + + bool get hasUndo => _history.hasUndo; + + bool get hasRedo => _history.hasRedo; + + 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); + if (autoAppendNewlineAfterImage) { + _autoAppendNewlineAfterImage(i, ops, op, res); + } + } + return res; + } + + 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 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` + final opInsertImage = op.isInsert && op.data is! String; + final nextOpIsLineBreak = i + 1 < ops.length && + ops[i + 1].isInsert && + ops[i + 1].data is String && + (ops[i + 1].data as String).startsWith('\n'); + if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) { + // automatically append '\n' for image + res.push(Operation.insert('\n')); + } + } + + Object _normalize(Object? data) { + if (data is String) { + return data; + } + + if (data is Embeddable) { + return data; + } + return Embeddable.fromJson(data as Map); + } + + void close() { + _observer.close(); + _history.clear(); + } + + String toPlainText() => _root.children.map((e) => e.toPlainText()).join(); + + void _loadDocument(Delta doc) { + if (doc.isEmpty) { + throw ArgumentError.value(doc, 'Document Delta cannot be empty.'); + } + + assert((doc.last.data as String).endsWith('\n')); + + var offset = 0; + for (final op in doc.toList()) { + if (!op.isInsert) { + throw ArgumentError.value(doc, + 'Document Delta can only contain insert operations but ${op.key} found.'); + } + final style = + op.attributes != null ? Style.fromJson(op.attributes) : null; + final data = _normalize(op.data); + _root.insert(offset, data, style); + offset += op.length!; + } + final node = _root.last; + if (node is Line && + node.parent is! Block && + node.style.isEmpty && + _root.childCount > 1) { + _root.remove(node); + } + } + + bool isEmpty() { + if (root.children.length != 1) { + return false; + } + + final node = root.children.first; + if (!node.isLast) { + return false; + } + + final delta = node.toDelta(); + return delta.length == 1 && + delta.first.data == '\n' && + delta.first.key == 'insert'; + } +} + +enum ChangeSource { + LOCAL, + REMOTE, +} diff --git a/lib/src/models/documents/history.dart b/lib/src/models/documents/history.dart new file mode 100644 index 00000000..d406505e --- /dev/null +++ b/lib/src/models/documents/history.dart @@ -0,0 +1,134 @@ +import 'package:tuple/tuple.dart'; + +import '../quill_delta.dart'; +import 'document.dart'; + +class History { + History({ + this.ignoreChange = false, + this.interval = 400, + this.maxStack = 100, + this.userOnly = false, + this.lastRecorded = 0, + }); + + final HistoryStack stack = HistoryStack.empty(); + + bool get hasUndo => stack.undo.isNotEmpty; + + bool get hasRedo => stack.redo.isNotEmpty; + + /// used for disable redo or undo function + bool ignoreChange; + + int lastRecorded; + + /// Collaborative editing's conditions should be true + final bool userOnly; + + ///max operation count for undo + final int maxStack; + + ///record delay + final int interval; + + void handleDocChange(Tuple3 change) { + if (ignoreChange) return; + if (!userOnly || change.item3 == ChangeSource.LOCAL) { + record(change.item2, change.item1); + } else { + transform(change.item2); + } + } + + void clear() { + stack.clear(); + } + + void record(Delta change, Delta before) { + if (change.isEmpty) return; + stack.redo.clear(); + var undoDelta = change.invert(before); + final timeStamp = DateTime.now().millisecondsSinceEpoch; + + if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) { + final lastDelta = stack.undo.removeLast(); + undoDelta = undoDelta.compose(lastDelta); + } else { + lastRecorded = timeStamp; + } + + if (undoDelta.isEmpty) return; + stack.undo.add(undoDelta); + + if (stack.undo.length > maxStack) { + stack.undo.removeAt(0); + } + } + + /// + ///It will override pre local undo delta,replaced by remote change + /// + void transform(Delta delta) { + transformStack(stack.undo, delta); + transformStack(stack.redo, delta); + } + + void transformStack(List stack, Delta delta) { + for (var i = stack.length - 1; i >= 0; i -= 1) { + final oldDelta = stack[i]; + stack[i] = delta.transform(oldDelta, true); + delta = oldDelta.transform(delta, false); + if (stack[i].length == 0) { + stack.removeAt(i); + } + } + } + + Tuple2 _change(Document doc, List source, List dest) { + if (source.isEmpty) { + return const Tuple2(false, 0); + } + final delta = source.removeLast(); + // look for insert or delete + int? len = 0; + final ops = delta.toList(); + for (var i = 0; i < ops.length; i++) { + if (ops[i].key == Operation.insertKey) { + len = ops[i].length; + } else if (ops[i].key == Operation.deleteKey) { + len = ops[i].length! * -1; + } + } + final base = Delta.from(doc.toDelta()); + final inverseDelta = delta.invert(base); + dest.add(inverseDelta); + lastRecorded = 0; + ignoreChange = true; + doc.compose(delta, ChangeSource.LOCAL); + ignoreChange = false; + return Tuple2(true, len); + } + + Tuple2 undo(Document doc) { + return _change(doc, stack.undo, stack.redo); + } + + Tuple2 redo(Document doc) { + return _change(doc, stack.redo, stack.undo); + } +} + +class HistoryStack { + HistoryStack.empty() + : undo = [], + redo = []; + + final List undo; + final List redo; + + void clear() { + undo.clear(); + redo.clear(); + } +} diff --git a/lib/src/models/documents/nodes/block.dart b/lib/src/models/documents/nodes/block.dart new file mode 100644 index 00000000..095f1183 --- /dev/null +++ b/lib/src/models/documents/nodes/block.dart @@ -0,0 +1,72 @@ +import '../../quill_delta.dart'; +import 'container.dart'; +import 'line.dart'; +import 'node.dart'; + +/// Represents a group of adjacent [Line]s with the same block style. +/// +/// Block elements are: +/// - Blockquote +/// - Header +/// - Indent +/// - List +/// - Text Alignment +/// - Text Direction +/// - Code Block +class Block extends Container { + /// Creates new unmounted [Block]. + @override + Node newInstance() => Block(); + + @override + Line get defaultChild => Line(); + + @override + Delta toDelta() { + return children + .map((child) => child.toDelta()) + .fold(Delta(), (a, b) => a.concat(b)); + } + + @override + void adjust() { + if (isEmpty) { + final sibling = previous; + unlink(); + if (sibling != null) { + sibling.adjust(); + } + return; + } + + var block = this; + final prev = block.previous; + // merging it with previous block if style is the same + if (!block.isFirst && + block.previous is Block && + prev!.style == block.style) { + block + ..moveChildToNewParent(prev as Container?) + ..unlink(); + block = prev as Block; + } + final next = block.next; + // merging it with next block if style is the same + if (!block.isLast && block.next is Block && next!.style == block.style) { + (next as Block).moveChildToNewParent(block); + next.unlink(); + } + } + + @override + String toString() { + final block = style.attributes.toString(); + final buffer = StringBuffer('§ {$block}\n'); + for (final child in children) { + final tree = child.isLast ? '└' : '├'; + buffer.write(' $tree $child'); + if (!child.isLast) buffer.writeln(); + } + return buffer.toString(); + } +} diff --git a/lib/src/models/documents/nodes/container.dart b/lib/src/models/documents/nodes/container.dart new file mode 100644 index 00000000..dbdd12d1 --- /dev/null +++ b/lib/src/models/documents/nodes/container.dart @@ -0,0 +1,160 @@ +import 'dart:collection'; + +import '../style.dart'; +import 'leaf.dart'; +import 'line.dart'; +import 'node.dart'; + +/// Container can accommodate other nodes. +/// +/// Delegates insert, retain and delete operations to children nodes. For each +/// operation container looks for a child at specified index position and +/// forwards operation to that child. +/// +/// Most of the operation handling logic is implemented by [Line] and [Text]. +abstract class Container extends Node { + final LinkedList _children = LinkedList(); + + /// List of children. + LinkedList get children => _children; + + /// Returns total number of child nodes in this container. + /// + /// To get text length of this container see [length]. + int get childCount => _children.length; + + /// Returns the first child [Node]. + Node get first => _children.first; + + /// Returns the last child [Node]. + Node get last => _children.last; + + /// Returns `true` if this container has no child nodes. + bool get isEmpty => _children.isEmpty; + + /// Returns `true` if this container has at least 1 child. + bool get isNotEmpty => _children.isNotEmpty; + + /// Returns an instance of default child for this container node. + /// + /// Always returns fresh instance. + T get defaultChild; + + /// Adds [node] to the end of this container children list. + void add(T node) { + assert(node?.parent == null); + node?.parent = this; + _children.add(node as Node); + } + + /// Adds [node] to the beginning of this container children list. + void addFirst(T node) { + assert(node?.parent == null); + node?.parent = this; + _children.addFirst(node as Node); + } + + /// Removes [node] from this container. + void remove(T node) { + assert(node?.parent == this); + node?.parent = null; + _children.remove(node as Node); + } + + /// Moves children of this node to [newParent]. + void moveChildToNewParent(Container? newParent) { + if (isEmpty) { + return; + } + + final last = newParent!.isEmpty ? null : newParent.last as T?; + while (isNotEmpty) { + final child = first as T; + child?.unlink(); + newParent.add(child); + } + + /// In case [newParent] already had children we need to make sure + /// combined list is optimized. + if (last != null) last.adjust(); + } + + /// Queries the child [Node] at specified character [offset] in this container. + /// + /// The result may contain the found node or `null` if no node is found + /// at specified offset. + /// + /// [ChildQuery.offset] is set to relative offset within returned child node + /// which points at the same character position in the document as the + /// original [offset]. + ChildQuery queryChild(int offset, bool inclusive) { + if (offset < 0 || offset > length) { + return ChildQuery(null, 0); + } + + for (final node in children) { + final len = node.length; + if (offset < len || (inclusive && offset == len && (node.isLast))) { + return ChildQuery(node, offset); + } + offset -= len; + } + return ChildQuery(null, 0); + } + + @override + String toPlainText() => children.map((child) => child.toPlainText()).join(); + + /// Content length of this node's children. + /// + /// To get number of children in this node use [childCount]. + @override + int get length => _children.fold(0, (cur, node) => cur + node.length); + + @override + void insert(int index, Object data, Style? style) { + assert(index == 0 || (index > 0 && index < length)); + + if (isNotEmpty) { + final child = queryChild(index, false); + child.node!.insert(child.offset, data, style); + return; + } + + // empty + assert(index == 0); + final node = defaultChild; + add(node); + node?.insert(index, data, style); + } + + @override + void retain(int index, int? length, Style? attributes) { + assert(isNotEmpty); + final child = queryChild(index, false); + child.node!.retain(child.offset, length, attributes); + } + + @override + void delete(int index, int? length) { + assert(isNotEmpty); + final child = queryChild(index, false); + child.node!.delete(child.offset, length); + } + + @override + String toString() => _children.join('\n'); +} + +/// Result of a child query in a [Container]. +class ChildQuery { + ChildQuery(this.node, this.offset); + + /// The child node if found, otherwise `null`. + final Node? node; + + /// Starting offset within the child [node] which points at the same + /// character in the document as the original offset passed to + /// [Container.queryChild] method. + final int offset; +} diff --git a/lib/src/models/documents/nodes/embed.dart b/lib/src/models/documents/nodes/embed.dart new file mode 100644 index 00000000..d6fe628a --- /dev/null +++ b/lib/src/models/documents/nodes/embed.dart @@ -0,0 +1,40 @@ +/// An object which can be embedded into a Quill document. +/// +/// See also: +/// +/// * [BlockEmbed] which represents a block embed. +class Embeddable { + Embeddable(this.type, this.data); + + /// The type of this object. + final String type; + + /// The data payload of this object. + final dynamic data; + + Map toJson() { + final m = {type: data}; + return m; + } + + static Embeddable fromJson(Map json) { + final m = Map.from(json); + assert(m.length == 1, 'Embeddable map has one key'); + + return BlockEmbed(m.keys.first, m.values.first); + } +} + +/// An object which occupies an entire line in a document and cannot co-exist +/// inline with regular text. +/// +/// There are two built-in embed types supported by Quill documents, however +/// the document model itself does not make any assumptions about the types +/// of embedded objects and allows users to define their own types. +class BlockEmbed extends Embeddable { + BlockEmbed(String type, String data) : super(type, data); + + static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr'); + + static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl); +} diff --git a/lib/src/models/documents/nodes/leaf.dart b/lib/src/models/documents/nodes/leaf.dart new file mode 100644 index 00000000..bd9292f5 --- /dev/null +++ b/lib/src/models/documents/nodes/leaf.dart @@ -0,0 +1,252 @@ +import 'dart:math' as math; + +import '../../quill_delta.dart'; +import '../style.dart'; +import 'embed.dart'; +import 'line.dart'; +import 'node.dart'; + +/// A leaf in Quill document tree. +abstract class Leaf extends Node { + /// Creates a new [Leaf] with specified [data]. + factory Leaf(Object data) { + if (data is Embeddable) { + return Embed(data); + } + final text = data as String; + assert(text.isNotEmpty); + return Text(text); + } + + Leaf.val(Object val) : _value = val; + + /// Contents of this node, either a String if this is a [Text] or an + /// [Embed] if this is an [BlockEmbed]. + Object get value => _value; + Object _value; + + @override + void applyStyle(Style value) { + assert(value.isInline || value.isIgnored || value.isEmpty, + 'Unable to apply Style to leaf: $value'); + super.applyStyle(value); + } + + @override + Line? get parent => super.parent as Line?; + + @override + int get length { + if (_value is String) { + return (_value as String).length; + } + // return 1 for embedded object + return 1; + } + + @override + Delta toDelta() { + final data = + _value is Embeddable ? (_value as Embeddable).toJson() : _value; + return Delta()..insert(data, style.toJson()); + } + + @override + void insert(int index, Object data, Style? style) { + assert(index >= 0 && index <= length); + final node = Leaf(data); + if (index < length) { + splitAt(index)!.insertBefore(node); + } else { + insertAfter(node); + } + node.format(style); + } + + @override + void retain(int index, int? len, Style? style) { + if (style == null) { + return; + } + + final local = math.min(length - index, len!); + final remain = len - local; + final node = _isolate(index, local); + + if (remain > 0) { + assert(node.next != null); + node.next!.retain(0, remain, style); + } + node.format(style); + } + + @override + void delete(int index, int? len) { + assert(index < length); + + final local = math.min(length - index, len!); + final target = _isolate(index, local); + final prev = target.previous as Leaf?; + final next = target.next as Leaf?; + target.unlink(); + + final remain = len - local; + if (remain > 0) { + assert(next != null); + next!.delete(0, remain); + } + + if (prev != null) { + prev.adjust(); + } + } + + /// Adjust this text node by merging it with adjacent nodes if they share + /// the same style. + @override + void adjust() { + if (this is Embed) { + // Embed nodes cannot be merged with text nor other embeds (in fact, + // there could be no two adjacent embeds on the same line since an + // embed occupies an entire line). + return; + } + + // This is a text node and it can only be merged with other text nodes. + var node = this as Text; + + // Merging it with previous node if style is the same. + final prev = node.previous; + if (!node.isFirst && prev is Text && prev.style == node.style) { + prev._value = prev.value + node.value; + node.unlink(); + node = prev; + } + + // Merging it with next node if style is the same. + final next = node.next; + if (!node.isLast && next is Text && next.style == node.style) { + node._value = node.value + next.value; + next.unlink(); + } + } + + /// Splits this leaf node at [index] and returns new node. + /// + /// If this is the last node in its list and [index] equals this node's + /// length then this method returns `null` as there is nothing left to split. + /// If there is another leaf node after this one and [index] equals this + /// node's length then the next leaf node is returned. + /// + /// If [index] equals to `0` then this node itself is returned unchanged. + /// + /// In case a new node is actually split from this one, it inherits this + /// node's style. + Leaf? splitAt(int index) { + assert(index >= 0 && index <= length); + if (index == 0) { + return this; + } + if (index == length) { + return isLast ? null : next as Leaf?; + } + + assert(this is Text); + final text = _value as String; + _value = text.substring(0, index); + final split = Leaf(text.substring(index))..applyStyle(style); + insertAfter(split); + return split; + } + + /// Cuts a leaf from [index] to the end of this node and returns new node + /// in detached state (e.g. [mounted] returns `false`). + /// + /// Splitting logic is identical to one described in [splitAt], meaning this + /// method may return `null`. + Leaf? cutAt(int index) { + assert(index >= 0 && index <= length); + final cut = splitAt(index); + cut?.unlink(); + return cut; + } + + /// Formats this node and optimizes it with adjacent leaf nodes if needed. + void format(Style? style) { + if (style != null && style.isNotEmpty) { + applyStyle(style); + } + adjust(); + } + + /// Isolates a new leaf starting at [index] with specified [length]. + /// + /// Splitting logic is identical to one described in [splitAt], with one + /// exception that it is required for [index] to always be less than this + /// node's length. As a result this method always returns a [LeafNode] + /// instance. Returned node may still be the same as this node + /// if provided [index] is `0`. + Leaf _isolate(int index, int length) { + assert( + index >= 0 && index < this.length && (index + length <= this.length)); + final target = splitAt(index)!..splitAt(length); + return target; + } +} + +/// A span of formatted text within a line in a Quill document. +/// +/// Text is a leaf node of a document tree. +/// +/// Parent of a text node is always a [Line], and as a consequence text +/// node's [value] cannot contain any line-break characters. +/// +/// See also: +/// +/// * [Embed], a leaf node representing an embeddable object. +/// * [Line], a node representing a line of text. +class Text extends Leaf { + Text([String text = '']) + : assert(!text.contains('\n')), + super.val(text); + + @override + Node newInstance() => Text(); + + @override + String get value => _value as String; + + @override + String toPlainText() => value; +} + +/// An embed node inside of a line in a Quill document. +/// +/// Embed node is a leaf node similar to [Text]. It represents an arbitrary +/// piece of non-textual content embedded into a document, such as, image, +/// horizontal rule, video, or any other object with defined structure, +/// like a tweet, for instance. +/// +/// Embed node's length is always `1` character and it is represented with +/// unicode object replacement character in the document text. +/// +/// Any inline style can be applied to an embed, however this does not +/// necessarily mean the embed will look according to that style. For instance, +/// applying "bold" style to an image gives no effect, while adding a "link" to +/// an image actually makes the image react to user's action. +class Embed extends Leaf { + Embed(Embeddable data) : super.val(data); + + static const kObjectReplacementCharacter = '\uFFFC'; + + @override + Node newInstance() => throw UnimplementedError(); + + @override + Embeddable get value => super.value as Embeddable; + + /// // Embed nodes are represented as unicode object replacement character in + // plain text. + @override + String toPlainText() => kObjectReplacementCharacter; +} diff --git a/lib/src/models/documents/nodes/line.dart b/lib/src/models/documents/nodes/line.dart new file mode 100644 index 00000000..fabfad4d --- /dev/null +++ b/lib/src/models/documents/nodes/line.dart @@ -0,0 +1,371 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; + +import '../../quill_delta.dart'; +import '../attribute.dart'; +import '../style.dart'; +import 'block.dart'; +import 'container.dart'; +import 'embed.dart'; +import 'leaf.dart'; +import 'node.dart'; + +/// A line of rich text in a Quill document. +/// +/// Line serves as a container for [Leaf]s, like [Text] and [Embed]. +/// +/// When a line contains an embed, it fully occupies the line, no other embeds +/// or text nodes are allowed. +class Line extends Container { + @override + Leaf get defaultChild => Text(); + + @override + int get length => super.length + 1; + + /// Returns `true` if this line contains an embedded object. + bool get hasEmbed { + if (childCount != 1) { + return false; + } + + return children.single is Embed; + } + + /// Returns next [Line] or `null` if this is the last line in the document. + Line? get nextLine { + if (!isLast) { + return next is Block ? (next as Block).first as Line? : next as Line?; + } + if (parent is! Block) { + return null; + } + + if (parent!.isLast) { + return null; + } + return parent!.next is Block + ? (parent!.next as Block).first as Line? + : parent!.next as Line?; + } + + @override + Node newInstance() => Line(); + + @override + Delta toDelta() { + final delta = children + .map((child) => child.toDelta()) + .fold(Delta(), (dynamic a, b) => a.concat(b)); + var attributes = style; + if (parent is Block) { + final block = parent as Block; + attributes = attributes.mergeAll(block.style); + } + delta.insert('\n', attributes.toJson()); + return delta; + } + + @override + String toPlainText() => '${super.toPlainText()}\n'; + + @override + String toString() { + final body = children.join(' → '); + final styleString = style.isNotEmpty ? ' $style' : ''; + return '¶ $body ⏎$styleString'; + } + + @override + void insert(int index, Object data, Style? style) { + if (data is Embeddable) { + // We do not check whether this line already has any children here as + // inserting an embed into a line with other text is acceptable from the + // Delta format perspective. + // We rely on heuristic rules to ensure that embeds occupy an entire line. + _insertSafe(index, data, style); + return; + } + + final text = data as String; + final lineBreak = text.indexOf('\n'); + if (lineBreak < 0) { + _insertSafe(index, text, style); + // No need to update line or block format since those attributes can only + // be attached to `\n` character and we already know it's not present. + return; + } + + final prefix = text.substring(0, lineBreak); + _insertSafe(index, prefix, style); + if (prefix.isNotEmpty) { + index += prefix.length; + } + + // Next line inherits our format. + final nextLine = _getNextLine(index); + + // Reset our format and unwrap from a block if needed. + clearStyle(); + if (parent is Block) { + _unwrap(); + } + + // Now we can apply new format and re-layout. + _format(style); + + // Continue with remaining part. + final remain = text.substring(lineBreak + 1); + nextLine.insert(0, remain, style); + } + + @override + void retain(int index, int? len, Style? style) { + if (style == null) { + return; + } + final thisLength = length; + + final local = math.min(thisLength - index, len!); + // If index is at newline character then this is a line/block style update. + final isLineFormat = (index + local == thisLength) && local == 1; + + if (isLineFormat) { + assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK), + 'It is not allowed to apply inline attributes to line itself.'); + _format(style); + } else { + // Otherwise forward to children as it's an inline format update. + assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE)); + assert(index + local != thisLength); + super.retain(index, local, style); + } + + final remain = len - local; + if (remain > 0) { + assert(nextLine != null); + nextLine!.retain(0, remain, style); + } + } + + @override + void delete(int index, int? len) { + final local = math.min(length - index, len!); + final isLFDeleted = index + local == length; // Line feed + if (isLFDeleted) { + // Our newline character deleted with all style information. + clearStyle(); + if (local > 1) { + // Exclude newline character from delete range for children. + super.delete(index, local - 1); + } + } else { + super.delete(index, local); + } + + final remaining = len - local; + if (remaining > 0) { + assert(nextLine != null); + nextLine!.delete(0, remaining); + } + + if (isLFDeleted && isNotEmpty) { + // Since we lost our line-break and still have child text nodes those must + // migrate to the next line. + + // nextLine might have been unmounted since last assert so we need to + // check again we still have a line after us. + assert(nextLine != null); + + // Move remaining children in this line to the next line so that all + // attributes of nextLine are preserved. + nextLine!.moveChildToNewParent(this); + moveChildToNewParent(nextLine); + } + + if (isLFDeleted) { + // Now we can remove this line. + final block = parent!; // remember reference before un-linking. + unlink(); + block.adjust(); + } + } + + /// Formats this line. + void _format(Style? newStyle) { + if (newStyle == null || newStyle.isEmpty) { + return; + } + + applyStyle(newStyle); + final blockStyle = newStyle.getBlockExceptHeader(); + if (blockStyle == null) { + return; + } // No block-level changes + + if (parent is Block) { + final parentStyle = (parent as Block).style.getBlocksExceptHeader(); + if (blockStyle.value == null) { + _unwrap(); + } else if (!const MapEquality() + .equals(newStyle.getBlocksExceptHeader(), parentStyle)) { + _unwrap(); + _applyBlockStyles(newStyle); + } // else the same style, no-op. + } else if (blockStyle.value != null) { + // Only wrap with a new block if this is not an unset + _applyBlockStyles(newStyle); + } + } + + void _applyBlockStyles(Style newStyle) { + var block = Block(); + for (final style in newStyle.getBlocksExceptHeader().values) { + block = block..applyAttribute(style); + } + _wrap(block); + block.adjust(); + } + + /// Wraps this line with new parent [block]. + /// + /// This line can not be in a [Block] when this method is called. + void _wrap(Block block) { + assert(parent != null && parent is! Block); + insertAfter(block); + unlink(); + block.add(this); + } + + /// Unwraps this line from it's parent [Block]. + /// + /// This method asserts if current [parent] of this line is not a [Block]. + void _unwrap() { + if (parent is! Block) { + throw ArgumentError('Invalid parent'); + } + final block = parent as Block; + + assert(block.children.contains(this)); + + if (isFirst) { + unlink(); + block.insertBefore(this); + } else if (isLast) { + unlink(); + block.insertAfter(this); + } else { + final before = block.clone() as Block; + block.insertBefore(before); + + var child = block.first as Line; + while (child != this) { + child.unlink(); + before.add(child); + child = block.first as Line; + } + unlink(); + block.insertBefore(this); + } + block.adjust(); + } + + Line _getNextLine(int index) { + assert(index == 0 || (index > 0 && index < length)); + + final line = clone() as Line; + insertAfter(line); + if (index == length - 1) { + return line; + } + + final query = queryChild(index, false); + while (!query.node!.isLast) { + final next = (last as Leaf)..unlink(); + line.addFirst(next); + } + final child = query.node as Leaf; + final cut = child.splitAt(query.offset); + cut?.unlink(); + line.addFirst(cut); + return line; + } + + void _insertSafe(int index, Object data, Style? style) { + assert(index == 0 || (index > 0 && index < length)); + + if (data is String) { + assert(!data.contains('\n')); + if (data.isEmpty) { + return; + } + } + + if (isEmpty) { + final child = Leaf(data); + add(child); + child.format(style); + } else { + final result = queryChild(index, true); + result.node!.insert(result.offset, data, style); + } + } + + /// Returns style for specified text range. + /// + /// Only attributes applied to all characters within this range are + /// included in the result. Inline and line level attributes are + /// handled separately, e.g.: + /// + /// - line attribute X is included in the result only if it exists for + /// every line within this range (partially included lines are counted). + /// - inline attribute X is included in the result only if it exists + /// for every character within this range (line-break characters excluded). + Style collectStyle(int offset, int len) { + final local = math.min(length - offset, len); + var result = Style(); + final excluded = {}; + + void _handle(Style style) { + if (result.isEmpty) { + excluded.addAll(style.values); + } else { + for (final attr in result.values) { + if (!style.containsKey(attr.key)) { + excluded.add(attr); + } + } + } + final remaining = style.removeAll(excluded); + result = result.removeAll(excluded); + result = result.mergeAll(remaining); + } + + final data = queryChild(offset, true); + var node = data.node as Leaf?; + if (node != null) { + result = result.mergeAll(node.style); + var pos = node.length - data.offset; + while (!node!.isLast && pos < local) { + node = node.next as Leaf?; + _handle(node!.style); + pos += node.length; + } + } + + result = result.mergeAll(style); + if (parent is Block) { + final block = parent as Block; + result = result.mergeAll(block.style); + } + + final remaining = len - local; + if (remaining > 0) { + final rest = nextLine!.collectStyle(0, remaining); + _handle(rest); + } + + return result; + } +} diff --git a/lib/src/models/documents/nodes/node.dart b/lib/src/models/documents/nodes/node.dart new file mode 100644 index 00000000..6bb0fb97 --- /dev/null +++ b/lib/src/models/documents/nodes/node.dart @@ -0,0 +1,131 @@ +import 'dart:collection'; + +import '../../quill_delta.dart'; +import '../attribute.dart'; +import '../style.dart'; +import 'container.dart'; +import 'line.dart'; + +/// An abstract node in a document tree. +/// +/// Represents a segment of a Quill document with specified [offset] +/// and [length]. +/// +/// The [offset] property is relative to [parent]. See also [documentOffset] +/// which provides absolute offset of this node within the document. +/// +/// The current parent node is exposed by the [parent] property. +abstract class Node extends LinkedListEntry { + /// Current parent of this node. May be null if this node is not mounted. + Container? parent; + + Style get style => _style; + Style _style = Style(); + + /// Returns `true` if this node is the first node in the [parent] list. + bool get isFirst => list!.first == this; + + /// Returns `true` if this node is the last node in the [parent] list. + bool get isLast => list!.last == this; + + /// Length of this node in characters. + int get length; + + Node clone() => newInstance()..applyStyle(style); + + /// Offset in characters of this node relative to [parent] node. + /// + /// To get offset of this node in the document see [documentOffset]. + int get offset { + var offset = 0; + + if (list == null || isFirst) { + return offset; + } + + var cur = this; + do { + cur = cur.previous!; + offset += cur.length; + } while (!cur.isFirst); + return offset; + } + + /// Offset in characters of this node in the document. + int get documentOffset { + final parentOffset = (parent is! Root) ? parent!.documentOffset : 0; + return parentOffset + offset; + } + + /// Returns `true` if this node contains character at specified [offset] in + /// the document. + bool containsOffset(int offset) { + final o = documentOffset; + return o <= offset && offset < o + length; + } + + void applyAttribute(Attribute attribute) { + _style = _style.merge(attribute); + } + + void applyStyle(Style value) { + _style = _style.mergeAll(value); + } + + void clearStyle() { + _style = Style(); + } + + @override + void insertBefore(Node entry) { + assert(entry.parent == null && parent != null); + entry.parent = parent; + super.insertBefore(entry); + } + + @override + void insertAfter(Node entry) { + assert(entry.parent == null && parent != null); + entry.parent = parent; + super.insertAfter(entry); + } + + @override + void unlink() { + assert(parent != null); + parent = null; + super.unlink(); + } + + void adjust() {/* no-op */} + + /// abstract methods begin + + Node newInstance(); + + String toPlainText(); + + Delta toDelta(); + + void insert(int index, Object data, Style? style); + + void retain(int index, int? len, Style? style); + + void delete(int index, int? len); + + /// abstract methods end +} + +/// Root node of document tree. +class Root extends Container> { + @override + Node newInstance() => Root(); + + @override + Container get defaultChild => Line(); + + @override + Delta toDelta() => children + .map((child) => child.toDelta()) + .fold(Delta(), (a, b) => a.concat(b)); +} diff --git a/lib/src/models/documents/style.dart b/lib/src/models/documents/style.dart new file mode 100644 index 00000000..fade1bb5 --- /dev/null +++ b/lib/src/models/documents/style.dart @@ -0,0 +1,127 @@ +import 'package:collection/collection.dart'; +import 'package:quiver/core.dart'; + +import 'attribute.dart'; + +/* Collection of style attributes */ +class Style { + Style() : _attributes = {}; + + Style.attr(this._attributes); + + final Map _attributes; + + static Style fromJson(Map? attributes) { + if (attributes == null) { + return Style(); + } + + final result = attributes.map((key, dynamic value) { + final attr = Attribute.fromKeyValue(key, value); + return MapEntry(key, attr); + }); + return Style.attr(result); + } + + Map? toJson() => _attributes.isEmpty + ? null + : _attributes.map((_, attribute) => + MapEntry(attribute.key, attribute.value)); + + Iterable get keys => _attributes.keys; + + Iterable get values => _attributes.values.sorted( + (a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b)); + + Map get attributes => _attributes; + + bool get isEmpty => _attributes.isEmpty; + + bool get isNotEmpty => _attributes.isNotEmpty; + + bool get isInline => isNotEmpty && values.every((item) => item.isInline); + + bool get isIgnored => + isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE); + + Attribute get single => _attributes.values.single; + + bool containsKey(String key) => _attributes.containsKey(key); + + Attribute? getBlockExceptHeader() { + for (final val in values) { + if (val.isBlockExceptHeader && val.value != null) { + return val; + } + } + for (final val in values) { + if (val.isBlockExceptHeader) { + return val; + } + } + return null; + } + + Map getBlocksExceptHeader() { + final m = {}; + attributes.forEach((key, value) { + if (Attribute.blockKeysExceptHeader.contains(key)) { + m[key] = value; + } + }); + return m; + } + + Style merge(Attribute attribute) { + final merged = Map.from(_attributes); + if (attribute.value == null) { + merged.remove(attribute.key); + } else { + merged[attribute.key] = attribute; + } + return Style.attr(merged); + } + + Style mergeAll(Style other) { + var result = Style.attr(_attributes); + for (final attribute in other.values) { + result = result.merge(attribute); + } + return result; + } + + Style removeAll(Set attributes) { + final merged = Map.from(_attributes); + attributes.map((item) => item.key).forEach(merged.remove); + return Style.attr(merged); + } + + Style put(Attribute attribute) { + final m = Map.from(attributes); + m[attribute.key] = attribute; + return Style.attr(m); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Style) { + return false; + } + final typedOther = other; + const eq = MapEquality(); + return eq.equals(_attributes, typedOther._attributes); + } + + @override + int get hashCode { + final hashes = + _attributes.entries.map((entry) => hash2(entry.key, entry.value)); + return hashObjects(hashes); + } + + @override + String toString() => "{${_attributes.values.join(', ')}}"; +} diff --git a/lib/src/models/quill_delta.dart b/lib/src/models/quill_delta.dart new file mode 100644 index 00000000..a0e608be --- /dev/null +++ b/lib/src/models/quill_delta.dart @@ -0,0 +1,684 @@ +// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code +// is governed by a BSD-style license that can be found in the LICENSE file. + +/// Implementation of Quill Delta format in Dart. +library quill_delta; + +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:quiver/core.dart'; + +const _attributeEquality = DeepCollectionEquality(); +const _valueEquality = DeepCollectionEquality(); + +/// Decoder function to convert raw `data` object into a user-defined data type. +/// +/// Useful with embedded content. +typedef DataDecoder = Object? Function(Object data); + +/// Default data decoder which simply passes through the original value. +Object? _passThroughDataDecoder(Object? data) => data; + +/// Operation performed on a rich-text document. +class Operation { + Operation._(this.key, this.length, this.data, Map? attributes) + : assert(_validKeys.contains(key), 'Invalid operation key "$key".'), + assert(() { + if (key != Operation.insertKey) return true; + return data is String ? data.length == length : length == 1; + }(), 'Length of insert operation must be equal to the data length.'), + _attributes = + attributes != null ? Map.from(attributes) : null; + + /// Creates operation which deletes [length] of characters. + factory Operation.delete(int length) => + Operation._(Operation.deleteKey, length, '', null); + + /// Creates operation which inserts [text] with optional [attributes]. + factory Operation.insert(dynamic data, [Map? attributes]) => + Operation._(Operation.insertKey, data is String ? data.length : 1, data, + attributes); + + /// Creates operation which retains [length] of characters and optionally + /// applies attributes. + factory Operation.retain(int? length, [Map? attributes]) => + Operation._(Operation.retainKey, length, '', attributes); + + /// Key of insert operations. + static const String insertKey = 'insert'; + + /// Key of delete operations. + static const String deleteKey = 'delete'; + + /// Key of retain operations. + static const String retainKey = 'retain'; + + /// Key of attributes collection. + static const String attributesKey = 'attributes'; + + static const List _validKeys = [insertKey, deleteKey, retainKey]; + + /// Key of this operation, can be "insert", "delete" or "retain". + final String key; + + /// Length of this operation. + final int? length; + + /// Payload of "insert" operation, for other types is set to empty string. + final Object? data; + + /// Rich-text attributes set by this operation, can be `null`. + Map? get attributes => + _attributes == null ? null : Map.from(_attributes!); + final Map? _attributes; + + /// Creates new [Operation] from JSON payload. + /// + /// If `dataDecoder` parameter is not null then it is used to additionally + /// decode the operation's data object. Only applied to insert operations. + static Operation fromJson(Map data, {DataDecoder? dataDecoder}) { + dataDecoder ??= _passThroughDataDecoder; + final map = Map.from(data); + if (map.containsKey(Operation.insertKey)) { + final data = dataDecoder(map[Operation.insertKey]); + final dataLength = data is String ? data.length : 1; + return Operation._( + Operation.insertKey, dataLength, data, map[Operation.attributesKey]); + } else if (map.containsKey(Operation.deleteKey)) { + final int? length = map[Operation.deleteKey]; + return Operation._(Operation.deleteKey, length, '', null); + } else if (map.containsKey(Operation.retainKey)) { + final int? length = map[Operation.retainKey]; + return Operation._( + Operation.retainKey, length, '', map[Operation.attributesKey]); + } + throw ArgumentError.value(data, 'Invalid data for Delta operation.'); + } + + /// Returns JSON-serializable representation of this operation. + Map toJson() { + final json = {key: value}; + if (_attributes != null) json[Operation.attributesKey] = attributes; + return json; + } + + /// Returns value of this operation. + /// + /// For insert operations this returns text, for delete and retain - length. + dynamic get value => (key == Operation.insertKey) ? data : length; + + /// Returns `true` if this is a delete operation. + bool get isDelete => key == Operation.deleteKey; + + /// Returns `true` if this is an insert operation. + bool get isInsert => key == Operation.insertKey; + + /// Returns `true` if this is a retain operation. + bool get isRetain => key == Operation.retainKey; + + /// Returns `true` if this operation has no attributes, e.g. is plain text. + bool get isPlain => _attributes == null || _attributes!.isEmpty; + + /// Returns `true` if this operation sets at least one attribute. + bool get isNotPlain => !isPlain; + + /// Returns `true` is this operation is empty. + /// + /// An operation is considered empty if its [length] is equal to `0`. + bool get isEmpty => length == 0; + + /// Returns `true` is this operation is not empty. + bool get isNotEmpty => length! > 0; + + @override + bool operator ==(other) { + if (identical(this, other)) return true; + if (other is! Operation) return false; + final typedOther = other; + return key == typedOther.key && + length == typedOther.length && + _valueEquality.equals(data, typedOther.data) && + hasSameAttributes(typedOther); + } + + /// Returns `true` if this operation has attribute specified by [name]. + bool hasAttribute(String name) => + isNotPlain && _attributes!.containsKey(name); + + /// Returns `true` if [other] operation has the same attributes as this one. + bool hasSameAttributes(Operation other) { + return _attributeEquality.equals(_attributes, other._attributes); + } + + @override + int get hashCode { + if (_attributes != null && _attributes!.isNotEmpty) { + final attrsHash = + hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value))); + return hash3(key, value, attrsHash); + } + return hash2(key, value); + } + + @override + String toString() { + final attr = attributes == null ? '' : ' + $attributes'; + final text = isInsert + ? (data is String + ? (data as String).replaceAll('\n', '⏎') + : data.toString()) + : '$length'; + return '$key⟨ $text ⟩$attr'; + } +} + +/// Delta represents a document or a modification of a document as a sequence of +/// insert, delete and retain operations. +/// +/// Delta consisting of only "insert" operations is usually referred to as +/// "document delta". When delta includes also "retain" or "delete" operations +/// it is a "change delta". +class Delta { + /// Creates new empty [Delta]. + factory Delta() => Delta._([]); + + Delta._(List operations) : _operations = operations; + + /// Creates new [Delta] from [other]. + factory Delta.from(Delta other) => + Delta._(List.from(other._operations)); + + /// Transforms two attribute sets. + static Map? transformAttributes( + Map? a, Map? b, bool priority) { + if (a == null) return b; + if (b == null) return null; + + if (!priority) return b; + + final result = b.keys.fold>({}, (attributes, key) { + if (!a.containsKey(key)) attributes[key] = b[key]; + return attributes; + }); + + return result.isEmpty ? null : result; + } + + /// Composes two attribute sets. + static Map? composeAttributes( + Map? a, Map? b, + {bool keepNull = false}) { + a ??= const {}; + b ??= const {}; + + final result = Map.from(a)..addAll(b); + final keys = result.keys.toList(growable: false); + + if (!keepNull) { + for (final key in keys) { + if (result[key] == null) result.remove(key); + } + } + + return result.isEmpty ? null : result; + } + + ///get anti-attr result base on base + static Map invertAttributes( + Map? attr, Map? base) { + attr ??= const {}; + base ??= const {}; + + final baseInverted = base.keys.fold({}, (dynamic memo, key) { + if (base![key] != attr![key] && attr.containsKey(key)) { + memo[key] = base[key]; + } + return memo; + }); + + final inverted = + Map.from(attr.keys.fold(baseInverted, (memo, key) { + if (base![key] != attr![key] && !base.containsKey(key)) { + memo[key] = null; + } + return memo; + })); + return inverted; + } + + final List _operations; + + int _modificationCount = 0; + + /// Creates [Delta] from de-serialized JSON representation. + /// + /// If `dataDecoder` parameter is not null then it is used to additionally + /// decode the operation's data object. Only applied to insert operations. + static Delta fromJson(List data, {DataDecoder? dataDecoder}) { + return Delta._(data + .map((op) => Operation.fromJson(op, dataDecoder: dataDecoder)) + .toList()); + } + + /// Returns list of operations in this delta. + List toList() => List.from(_operations); + + /// Returns JSON-serializable version of this delta. + List toJson() => toList().map((operation) => operation.toJson()).toList(); + + /// Returns `true` if this delta is empty. + bool get isEmpty => _operations.isEmpty; + + /// Returns `true` if this delta is not empty. + bool get isNotEmpty => _operations.isNotEmpty; + + /// Returns number of operations in this delta. + int get length => _operations.length; + + /// Returns [Operation] at specified [index] in this delta. + Operation operator [](int index) => _operations[index]; + + /// Returns [Operation] at specified [index] in this delta. + Operation elementAt(int index) => _operations.elementAt(index); + + /// Returns the first [Operation] in this delta. + Operation get first => _operations.first; + + /// Returns the last [Operation] in this delta. + Operation get last => _operations.last; + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) return true; + if (other is! Delta) return false; + final typedOther = other; + const comparator = ListEquality(DefaultEquality()); + return comparator.equals(_operations, typedOther._operations); + } + + @override + int get hashCode => hashObjects(_operations); + + /// Retain [count] of characters from current position. + void retain(int count, [Map? attributes]) { + assert(count >= 0); + if (count == 0) return; // no-op + push(Operation.retain(count, attributes)); + } + + /// Insert [data] at current position. + void insert(dynamic data, [Map? attributes]) { + if (data is String && data.isEmpty) return; // no-op + push(Operation.insert(data, attributes)); + } + + /// Delete [count] characters from current position. + void delete(int count) { + assert(count >= 0); + if (count == 0) return; + push(Operation.delete(count)); + } + + void _mergeWithTail(Operation operation) { + assert(isNotEmpty); + assert(last.key == operation.key); + assert(operation.data is String && last.data is String); + + final length = operation.length! + last.length!; + final lastText = last.data as String; + final opText = operation.data as String; + final resultText = lastText + opText; + final index = _operations.length; + _operations.replaceRange(index - 1, index, [ + Operation._(operation.key, length, resultText, operation.attributes), + ]); + } + + /// Pushes new operation into this delta. + /// + /// Performs compaction by composing [operation] with current tail operation + /// of this delta, when possible. For instance, if current tail is + /// `insert('abc')` and pushed operation is `insert('123')` then existing + /// tail is replaced with `insert('abc123')` - a compound result of the two + /// operations. + void push(Operation operation) { + if (operation.isEmpty) return; + + var index = _operations.length; + final lastOp = _operations.isNotEmpty ? _operations.last : null; + if (lastOp != null) { + if (lastOp.isDelete && operation.isDelete) { + _mergeWithTail(operation); + return; + } + + if (lastOp.isDelete && operation.isInsert) { + index -= 1; // Always insert before deleting + final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null; + if (nLastOp == null) { + _operations.insert(0, operation); + return; + } + } + + if (lastOp.isInsert && operation.isInsert) { + if (lastOp.hasSameAttributes(operation) && + operation.data is String && + lastOp.data is String) { + _mergeWithTail(operation); + return; + } + } + + if (lastOp.isRetain && operation.isRetain) { + if (lastOp.hasSameAttributes(operation)) { + _mergeWithTail(operation); + return; + } + } + } + if (index == _operations.length) { + _operations.add(operation); + } else { + final opAtIndex = _operations.elementAt(index); + _operations.replaceRange(index, index + 1, [operation, opAtIndex]); + } + _modificationCount++; + } + + /// Composes next operation from [thisIter] and [otherIter]. + /// + /// Returns new operation or `null` if operations from [thisIter] and + /// [otherIter] nullify each other. For instance, for the pair `insert('abc')` + /// and `delete(3)` composition result would be empty string. + Operation? _composeOperation( + DeltaIterator thisIter, DeltaIterator otherIter) { + if (otherIter.isNextInsert) return otherIter.next(); + if (thisIter.isNextDelete) return thisIter.next(); + + final length = math.min(thisIter.peekLength(), otherIter.peekLength()); + final thisOp = thisIter.next(length as int); + final otherOp = otherIter.next(length); + assert(thisOp.length == otherOp.length); + + if (otherOp.isRetain) { + final attributes = composeAttributes( + thisOp.attributes, + otherOp.attributes, + keepNull: thisOp.isRetain, + ); + if (thisOp.isRetain) { + return Operation.retain(thisOp.length, attributes); + } else if (thisOp.isInsert) { + return Operation.insert(thisOp.data, attributes); + } else { + throw StateError('Unreachable'); + } + } else { + // otherOp == delete && thisOp in [retain, insert] + assert(otherOp.isDelete); + if (thisOp.isRetain) return otherOp; + assert(thisOp.isInsert); + // otherOp(delete) + thisOp(insert) => null + } + return null; + } + + /// Composes this delta with [other] and returns new [Delta]. + /// + /// It is not required for this and [other] delta to represent a document + /// delta (consisting only of insert operations). + Delta compose(Delta other) { + final result = Delta(); + final thisIter = DeltaIterator(this); + final otherIter = DeltaIterator(other); + + while (thisIter.hasNext || otherIter.hasNext) { + final newOp = _composeOperation(thisIter, otherIter); + if (newOp != null) result.push(newOp); + } + return result..trim(); + } + + /// Transforms next operation from [otherIter] against next operation in + /// [thisIter]. + /// + /// Returns `null` if both operations nullify each other. + Operation? _transformOperation( + DeltaIterator thisIter, DeltaIterator otherIter, bool priority) { + if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) { + return Operation.retain(thisIter.next().length); + } else if (otherIter.isNextInsert) { + return otherIter.next(); + } + + final length = math.min(thisIter.peekLength(), otherIter.peekLength()); + final thisOp = thisIter.next(length as int); + final otherOp = otherIter.next(length); + assert(thisOp.length == otherOp.length); + + // At this point only delete and retain operations are possible. + if (thisOp.isDelete) { + // otherOp is either delete or retain, so they nullify each other. + return null; + } else if (otherOp.isDelete) { + return otherOp; + } else { + // Retain otherOp which is either retain or insert. + return Operation.retain( + length, + transformAttributes(thisOp.attributes, otherOp.attributes, priority), + ); + } + } + + /// Transforms [other] delta against operations in this delta. + Delta transform(Delta other, bool priority) { + final result = Delta(); + final thisIter = DeltaIterator(this); + final otherIter = DeltaIterator(other); + + while (thisIter.hasNext || otherIter.hasNext) { + final newOp = _transformOperation(thisIter, otherIter, priority); + if (newOp != null) result.push(newOp); + } + return result..trim(); + } + + /// Removes trailing retain operation with empty attributes, if present. + void trim() { + if (isNotEmpty) { + final last = _operations.last; + if (last.isRetain && last.isPlain) _operations.removeLast(); + } + } + + /// Concatenates [other] with this delta and returns the result. + Delta concat(Delta other) { + final result = Delta.from(this); + if (other.isNotEmpty) { + // In case first operation of other can be merged with last operation in + // our list. + result.push(other._operations.first); + result._operations.addAll(other._operations.sublist(1)); + } + return result; + } + + /// Inverts this delta against [base]. + /// + /// Returns new delta which negates effect of this delta when applied to + /// [base]. This is an equivalent of "undo" operation on deltas. + Delta invert(Delta base) { + final inverted = Delta(); + if (base.isEmpty) return inverted; + + var baseIndex = 0; + for (final op in _operations) { + if (op.isInsert) { + inverted.delete(op.length!); + } else if (op.isRetain && op.isPlain) { + inverted.retain(op.length!); + baseIndex += op.length!; + } else if (op.isDelete || (op.isRetain && op.isNotPlain)) { + final length = op.length!; + final sliceDelta = base.slice(baseIndex, baseIndex + length); + sliceDelta.toList().forEach((baseOp) { + if (op.isDelete) { + inverted.push(baseOp); + } else if (op.isRetain && op.isNotPlain) { + final invertAttr = + invertAttributes(op.attributes, baseOp.attributes); + inverted.retain( + baseOp.length!, invertAttr.isEmpty ? null : invertAttr); + } + }); + baseIndex += length; + } else { + throw StateError('Unreachable'); + } + } + inverted.trim(); + return inverted; + } + + /// Returns slice of this delta from [start] index (inclusive) to [end] + /// (exclusive). + Delta slice(int start, [int? end]) { + final delta = Delta(); + var index = 0; + final opIterator = DeltaIterator(this); + + final actualEnd = end ?? double.infinity; + + while (index < actualEnd && opIterator.hasNext) { + Operation op; + if (index < start) { + op = opIterator.next(start - index); + } else { + op = opIterator.next(actualEnd - index as int); + delta.push(op); + } + index += op.length!; + } + return delta; + } + + /// Transforms [index] against this delta. + /// + /// Any "delete" operation before specified [index] shifts it backward, as + /// well as any "insert" operation shifts it forward. + /// + /// The [force] argument is used to resolve scenarios when there is an + /// insert operation at the same position as [index]. If [force] is set to + /// `true` (default) then position is forced to shift forward, otherwise + /// position stays at the same index. In other words setting [force] to + /// `false` gives higher priority to the transformed position. + /// + /// Useful to adjust caret or selection positions. + int transformPosition(int index, {bool force = true}) { + final iter = DeltaIterator(this); + var offset = 0; + while (iter.hasNext && offset <= index) { + final op = iter.next(); + if (op.isDelete) { + index -= math.min(op.length!, index - offset); + continue; + } else if (op.isInsert && (offset < index || force)) { + index += op.length!; + } + offset += op.length!; + } + return index; + } + + @override + String toString() => _operations.join('\n'); +} + +/// Specialized iterator for [Delta]s. +class DeltaIterator { + DeltaIterator(this.delta) : _modificationCount = delta._modificationCount; + + final Delta delta; + final int _modificationCount; + int _index = 0; + num _offset = 0; + + bool get isNextInsert => nextOperationKey == Operation.insertKey; + + bool get isNextDelete => nextOperationKey == Operation.deleteKey; + + bool get isNextRetain => nextOperationKey == Operation.retainKey; + + String? get nextOperationKey { + if (_index < delta.length) { + return delta.elementAt(_index).key; + } else { + return null; + } + } + + bool get hasNext => peekLength() < double.infinity; + + /// Returns length of next operation without consuming it. + /// + /// Returns [double.infinity] if there is no more operations left to iterate. + num peekLength() { + if (_index < delta.length) { + final operation = delta._operations[_index]; + return operation.length! - _offset; + } + return double.infinity; + } + + /// Consumes and returns next operation. + /// + /// Optional [length] specifies maximum length of operation to return. Note + /// that actual length of returned operation may be less than specified value. + Operation next([int length = 4294967296]) { + if (_modificationCount != delta._modificationCount) { + throw ConcurrentModificationError(delta); + } + + if (_index < delta.length) { + final op = delta.elementAt(_index); + final opKey = op.key; + final opAttributes = op.attributes; + final _currentOffset = _offset; + final actualLength = math.min(op.length! - _currentOffset, length); + if (actualLength == op.length! - _currentOffset) { + _index++; + _offset = 0; + } else { + _offset += actualLength; + } + final opData = op.isInsert && op.data is String + ? (op.data as String).substring( + _currentOffset as int, _currentOffset + (actualLength as int)) + : op.data; + final opIsNotEmpty = + opData is String ? opData.isNotEmpty : true; // embeds are never empty + final opLength = opData is String ? opData.length : 1; + final opActualLength = opIsNotEmpty ? opLength : actualLength as int; + return Operation._(opKey, opActualLength, opData, opAttributes); + } + return Operation.retain(length); + } + + /// Skips [length] characters in source delta. + /// + /// Returns last skipped operation, or `null` if there was nothing to skip. + Operation? skip(int length) { + var skipped = 0; + Operation? op; + while (skipped < length && hasNext) { + final opLength = peekLength(); + final skip = math.min(length - skipped, opLength); + op = next(skip as int); + skipped += op.length!; + } + return op; + } +} diff --git a/lib/src/models/rules/delete.dart b/lib/src/models/rules/delete.dart new file mode 100644 index 00000000..e6682f94 --- /dev/null +++ b/lib/src/models/rules/delete.dart @@ -0,0 +1,124 @@ +import '../documents/attribute.dart'; +import '../quill_delta.dart'; +import 'rule.dart'; + +abstract class DeleteRule extends Rule { + const DeleteRule(); + + @override + RuleType get type => RuleType.DELETE; + + @override + void validateArgs(int? len, Object? data, Attribute? attribute) { + assert(len != null); + assert(data == null); + assert(attribute == null); + } +} + +class CatchAllDeleteRule extends DeleteRule { + const CatchAllDeleteRule(); + + @override + Delta applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + return Delta() + ..retain(index) + ..delete(len!); + } +} + +class PreserveLineStyleOnMergeRule extends DeleteRule { + const PreserveLineStyleOnMergeRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + final itr = DeltaIterator(document)..skip(index); + var op = itr.next(1); + if (op.data != '\n') { + return null; + } + + final isNotPlain = op.isNotPlain; + final attrs = op.attributes; + + itr.skip(len! - 1); + final delta = Delta() + ..retain(index) + ..delete(len); + + while (itr.hasNext) { + op = itr.next(); + final text = op.data is String ? (op.data as String?)! : ''; + final lineBreak = text.indexOf('\n'); + if (lineBreak == -1) { + delta.retain(op.length!); + continue; + } + + var attributes = op.attributes == null + ? null + : op.attributes!.map( + (key, dynamic value) => MapEntry(key, null)); + + if (isNotPlain) { + attributes ??= {}; + attributes.addAll(attrs!); + } + delta..retain(lineBreak)..retain(1, attributes); + break; + } + return delta; + } +} + +class EnsureEmbedLineRule extends DeleteRule { + const EnsureEmbedLineRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + final itr = DeltaIterator(document); + + var op = itr.skip(index); + int? indexDelta = 0, lengthDelta = 0, remain = len; + var embedFound = op != null && op.data is! String; + final hasLineBreakBefore = + !embedFound && (op == null || (op.data as String).endsWith('\n')); + if (embedFound) { + var candidate = itr.next(1); + if (remain != null) { + remain--; + if (candidate.data == '\n') { + indexDelta++; + lengthDelta--; + + candidate = itr.next(1); + remain--; + if (candidate.data == '\n') { + lengthDelta++; + } + } + } + } + + op = itr.skip(remain!); + if (op != null && + (op.data is String ? op.data as String? : '')!.endsWith('\n')) { + final candidate = itr.next(1); + if (candidate.data is! String && !hasLineBreakBefore) { + embedFound = true; + lengthDelta--; + } + } + + if (!embedFound) { + return null; + } + + return Delta() + ..retain(index + indexDelta) + ..delete(len! + lengthDelta); + } +} diff --git a/lib/src/models/rules/format.dart b/lib/src/models/rules/format.dart new file mode 100644 index 00000000..be201925 --- /dev/null +++ b/lib/src/models/rules/format.dart @@ -0,0 +1,132 @@ +import '../documents/attribute.dart'; +import '../quill_delta.dart'; +import 'rule.dart'; + +abstract class FormatRule extends Rule { + const FormatRule(); + + @override + RuleType get type => RuleType.FORMAT; + + @override + void validateArgs(int? len, Object? data, Attribute? attribute) { + assert(len != null); + assert(data == null); + assert(attribute != null); + } +} + +class ResolveLineFormatRule extends FormatRule { + const ResolveLineFormatRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (attribute!.scope != AttributeScope.BLOCK) { + return null; + } + + var delta = Delta()..retain(index); + final itr = DeltaIterator(document)..skip(index); + Operation op; + for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { + op = itr.next(len - cur); + if (op.data is! String || !(op.data as String).contains('\n')) { + delta.retain(op.length!); + continue; + } + final text = op.data as String; + final tmp = Delta(); + var offset = 0; + + for (var lineBreak = text.indexOf('\n'); + lineBreak >= 0; + lineBreak = text.indexOf('\n', offset)) { + tmp..retain(lineBreak - offset)..retain(1, attribute.toJson()); + offset = lineBreak + 1; + } + tmp.retain(text.length - offset); + delta = delta.concat(tmp); + } + + while (itr.hasNext) { + op = itr.next(); + final text = op.data is String ? (op.data as String?)! : ''; + final lineBreak = text.indexOf('\n'); + if (lineBreak < 0) { + delta.retain(op.length!); + continue; + } + delta..retain(lineBreak)..retain(1, attribute.toJson()); + break; + } + return delta; + } +} + +class FormatLinkAtCaretPositionRule extends FormatRule { + const FormatLinkAtCaretPositionRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (attribute!.key != Attribute.link.key || len! > 0) { + return null; + } + + final delta = Delta(); + final itr = DeltaIterator(document); + final before = itr.skip(index), after = itr.next(); + int? beg = index, retain = 0; + if (before != null && before.hasAttribute(attribute.key)) { + beg -= before.length!; + retain = before.length; + } + if (after.hasAttribute(attribute.key)) { + if (retain != null) retain += after.length!; + } + if (retain == 0) { + return null; + } + + delta..retain(beg)..retain(retain!, attribute.toJson()); + return delta; + } +} + +class ResolveInlineFormatRule extends FormatRule { + const ResolveInlineFormatRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (attribute!.scope != AttributeScope.INLINE) { + return null; + } + + final delta = Delta()..retain(index); + final itr = DeltaIterator(document)..skip(index); + + Operation op; + for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { + op = itr.next(len - cur); + final text = op.data is String ? (op.data as String?)! : ''; + var lineBreak = text.indexOf('\n'); + if (lineBreak < 0) { + delta.retain(op.length!, attribute.toJson()); + continue; + } + var pos = 0; + while (lineBreak >= 0) { + delta..retain(lineBreak - pos, attribute.toJson())..retain(1); + pos = lineBreak + 1; + lineBreak = text.indexOf('\n', pos); + } + if (pos < op.length!) { + delta.retain(op.length! - pos, attribute.toJson()); + } + } + + return delta; + } +} diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart new file mode 100644 index 00000000..5801a10e --- /dev/null +++ b/lib/src/models/rules/insert.dart @@ -0,0 +1,413 @@ +import 'package:tuple/tuple.dart'; + +import '../documents/attribute.dart'; +import '../documents/style.dart'; +import '../quill_delta.dart'; +import 'rule.dart'; + +abstract class InsertRule extends Rule { + const InsertRule(); + + @override + RuleType get type => RuleType.INSERT; + + @override + void validateArgs(int? len, Object? data, Attribute? attribute) { + assert(data != null); + assert(attribute == null); + } +} + +class PreserveLineStyleOnSplitRule extends InsertRule { + const PreserveLineStyleOnSplitRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String || data != '\n') { + return null; + } + + final itr = DeltaIterator(document); + final before = itr.skip(index); + if (before == null || + before.data is! String || + (before.data as String).endsWith('\n')) { + return null; + } + final after = itr.next(); + if (after.data is! String || (after.data as String).startsWith('\n')) { + return null; + } + + final text = after.data as String; + + final delta = Delta()..retain(index + (len ?? 0)); + if (text.contains('\n')) { + assert(after.isPlain); + delta.insert('\n'); + return delta; + } + final nextNewLine = _getNextNewLine(itr); + final attributes = nextNewLine.item1?.attributes; + + return delta..insert('\n', attributes); + } +} + +/// Preserves block style when user inserts text containing newlines. +/// +/// This rule handles: +/// +/// * inserting a new line in a block +/// * pasting text containing multiple lines of text in a block +/// +/// This rule may also be activated for changes triggered by auto-correct. +class PreserveBlockStyleOnInsertRule extends InsertRule { + const PreserveBlockStyleOnInsertRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String || !data.contains('\n')) { + // Only interested in text containing at least one newline character. + return null; + } + + final itr = DeltaIterator(document)..skip(index); + + // Look for the next newline. + final nextNewLine = _getNextNewLine(itr); + final lineStyle = + Style.fromJson(nextNewLine.item1?.attributes ?? {}); + + final blockStyle = lineStyle.getBlocksExceptHeader(); + // Are we currently in a block? If not then ignore. + if (blockStyle.isEmpty) { + return null; + } + + Map? resetStyle; + // If current line had heading style applied to it we'll need to move this + // style to the newly inserted line before it and reset style of the + // original line. + if (lineStyle.containsKey(Attribute.header.key)) { + resetStyle = Attribute.header.toJson(); + } + + // Go over each inserted line and ensure block style is applied. + final lines = data.split('\n'); + final delta = Delta()..retain(index + (len ?? 0)); + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (line.isNotEmpty) { + delta.insert(line); + } + if (i == 0) { + // The first line should inherit the lineStyle entirely. + delta.insert('\n', lineStyle.toJson()); + } else if (i < lines.length - 1) { + // we don't want to insert a newline after the last chunk of text, so -1 + delta.insert('\n', blockStyle); + } + } + + // Reset style of the original newline character if needed. + if (resetStyle != null) { + delta + ..retain(nextNewLine.item2!) + ..retain((nextNewLine.item1!.data as String).indexOf('\n')) + ..retain(1, resetStyle); + } + + return delta; + } +} + +/// Heuristic rule to exit current block when user inserts two consecutive +/// newlines. +/// +/// This rule is only applied when the cursor is on the last line of a block. +/// When the cursor is in the middle of a block we allow adding empty lines +/// and preserving the block's style. +class AutoExitBlockRule extends InsertRule { + const AutoExitBlockRule(); + + bool _isEmptyLine(Operation? before, Operation? after) { + if (before == null) { + return true; + } + return before.data is String && + (before.data as String).endsWith('\n') && + after!.data is String && + (after.data as String).startsWith('\n'); + } + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String || data != '\n') { + return null; + } + + final itr = DeltaIterator(document); + final prev = itr.skip(index), cur = itr.next(); + final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader(); + // We are not in a block, ignore. + if (cur.isPlain || blockStyle == null) { + return null; + } + // We are not on an empty line, ignore. + if (!_isEmptyLine(prev, cur)) { + return null; + } + + // We are on an empty line. Now we need to determine if we are on the + // last line of a block. + // First check if `cur` length is greater than 1, this would indicate + // that it contains multiple newline characters which share the same style. + // This would mean we are not on the last line yet. + // `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline + if ((cur.value as String).length > 1) { + // We are not on the last line of this block, ignore. + return null; + } + + // Keep looking for the next newline character to see if it shares the same + // block style as `cur`. + final nextNewLine = _getNextNewLine(itr); + if (nextNewLine.item1 != null && + nextNewLine.item1!.attributes != null && + Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() == + blockStyle) { + // We are not at the end of this block, ignore. + return null; + } + + // Here we now know that the line after `cur` is not in the same block + // therefore we can exit this block. + final attributes = cur.attributes ?? {}; + final k = attributes.keys + .firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k)); + attributes[k] = null; + // retain(1) should be '\n', set it with no attribute + return Delta()..retain(index + (len ?? 0))..retain(1, attributes); + } +} + +class ResetLineFormatOnNewLineRule extends InsertRule { + const ResetLineFormatOnNewLineRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String || data != '\n') { + return null; + } + + final itr = DeltaIterator(document)..skip(index); + final cur = itr.next(); + if (cur.data is! String || !(cur.data as String).startsWith('\n')) { + return null; + } + + Map? resetStyle; + if (cur.attributes != null && + cur.attributes!.containsKey(Attribute.header.key)) { + resetStyle = Attribute.header.toJson(); + } + return Delta() + ..retain(index + (len ?? 0)) + ..insert('\n', cur.attributes) + ..retain(1, resetStyle) + ..trim(); + } +} + +class InsertEmbedsRule extends InsertRule { + const InsertEmbedsRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is String) { + return null; + } + + final delta = Delta()..retain(index + (len ?? 0)); + final itr = DeltaIterator(document); + final prev = itr.skip(index), cur = itr.next(); + + final textBefore = prev?.data is String ? prev!.data as String? : ''; + final textAfter = cur.data is String ? (cur.data as String?)! : ''; + + final isNewlineBefore = prev == null || textBefore!.endsWith('\n'); + final isNewlineAfter = textAfter.startsWith('\n'); + + if (isNewlineBefore && isNewlineAfter) { + return delta..insert(data); + } + + Map? lineStyle; + if (textAfter.contains('\n')) { + lineStyle = cur.attributes; + } else { + while (itr.hasNext) { + final op = itr.next(); + if ((op.data is String ? op.data as String? : '')!.contains('\n')) { + lineStyle = op.attributes; + break; + } + } + } + + if (!isNewlineBefore) { + delta.insert('\n', lineStyle); + } + delta.insert(data); + if (!isNewlineAfter) { + delta.insert('\n'); + } + return delta; + } +} + +class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { + const ForceNewlineForInsertsAroundEmbedRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String) { + return null; + } + + final text = data; + final itr = DeltaIterator(document); + final prev = itr.skip(index); + final cur = itr.next(); + final cursorBeforeEmbed = cur.data is! String; + final cursorAfterEmbed = prev != null && prev.data is! String; + + if (!cursorBeforeEmbed && !cursorAfterEmbed) { + return null; + } + final delta = Delta()..retain(index + (len ?? 0)); + if (cursorBeforeEmbed && !text.endsWith('\n')) { + return delta..insert(text)..insert('\n'); + } + if (cursorAfterEmbed && !text.startsWith('\n')) { + return delta..insert('\n')..insert(text); + } + return delta..insert(text); + } +} + +class AutoFormatLinksRule extends InsertRule { + const AutoFormatLinksRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String || data != ' ') { + return null; + } + + final itr = DeltaIterator(document); + final prev = itr.skip(index); + if (prev == null || prev.data is! String) { + return null; + } + + try { + final cand = (prev.data as String).split('\n').last.split(' ').last; + final link = Uri.parse(cand); + if (!['https', 'http'].contains(link.scheme)) { + return null; + } + final attributes = prev.attributes ?? {}; + + if (attributes.containsKey(Attribute.link.key)) { + return null; + } + + attributes.addAll(LinkAttribute(link.toString()).toJson()); + return Delta() + ..retain(index + (len ?? 0) - cand.length) + ..retain(cand.length, attributes) + ..insert(data, prev.attributes); + } on FormatException { + return null; + } + } +} + +class PreserveInlineStylesRule extends InsertRule { + const PreserveInlineStylesRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + if (data is! String || data.contains('\n')) { + return null; + } + + final itr = DeltaIterator(document); + final prev = itr.skip(index); + if (prev == null || + prev.data is! String || + (prev.data as String).contains('\n')) { + return null; + } + + final attributes = prev.attributes; + final text = data; + if (attributes == null || !attributes.containsKey(Attribute.link.key)) { + return Delta() + ..retain(index + (len ?? 0)) + ..insert(text, attributes); + } + + attributes.remove(Attribute.link.key); + final delta = Delta() + ..retain(index + (len ?? 0)) + ..insert(text, attributes.isEmpty ? null : attributes); + final next = itr.next(); + + final nextAttributes = next.attributes ?? const {}; + if (!nextAttributes.containsKey(Attribute.link.key)) { + return delta; + } + if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) { + return Delta() + ..retain(index + (len ?? 0)) + ..insert(text, attributes); + } + return delta; + } +} + +class CatchAllInsertRule extends InsertRule { + const CatchAllInsertRule(); + + @override + Delta applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + return Delta() + ..retain(index + (len ?? 0)) + ..insert(data); + } +} + +Tuple2 _getNextNewLine(DeltaIterator iterator) { + Operation op; + for (var skipped = 0; iterator.hasNext; skipped += op.length!) { + op = iterator.next(); + final lineBreak = + (op.data is String ? op.data as String? : '')!.indexOf('\n'); + if (lineBreak >= 0) { + return Tuple2(op, skipped); + } + } + return const Tuple2(null, null); +} diff --git a/lib/src/models/rules/rule.dart b/lib/src/models/rules/rule.dart new file mode 100644 index 00000000..042f1aaa --- /dev/null +++ b/lib/src/models/rules/rule.dart @@ -0,0 +1,77 @@ +import '../documents/attribute.dart'; +import '../documents/document.dart'; +import '../quill_delta.dart'; +import 'delete.dart'; +import 'format.dart'; +import 'insert.dart'; + +enum RuleType { INSERT, DELETE, FORMAT } + +abstract class Rule { + const Rule(); + + Delta? apply(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + validateArgs(len, data, attribute); + return applyRule(document, index, + len: len, data: data, attribute: attribute); + } + + void validateArgs(int? len, Object? data, Attribute? attribute); + + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}); + + RuleType get type; +} + +class Rules { + Rules(this._rules); + + List _customRules = []; + + final List _rules; + static final Rules _instance = Rules([ + const FormatLinkAtCaretPositionRule(), + const ResolveLineFormatRule(), + const ResolveInlineFormatRule(), + const InsertEmbedsRule(), + const ForceNewlineForInsertsAroundEmbedRule(), + const AutoExitBlockRule(), + const PreserveBlockStyleOnInsertRule(), + const PreserveLineStyleOnSplitRule(), + const ResetLineFormatOnNewLineRule(), + const AutoFormatLinksRule(), + const PreserveInlineStylesRule(), + const CatchAllInsertRule(), + const EnsureEmbedLineRule(), + const PreserveLineStyleOnMergeRule(), + const CatchAllDeleteRule(), + ]); + + 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 _customRules + _rules) { + if (rule.type != ruleType) { + continue; + } + try { + final result = rule.apply(delta, index, + len: len, data: data, attribute: attribute); + if (result != null) { + return result..trim(); + } + } catch (e) { + rethrow; + } + } + throw 'Apply rules failed'; + } +} diff --git a/lib/src/utils/color.dart b/lib/src/utils/color.dart new file mode 100644 index 00000000..93b6e12b --- /dev/null +++ b/lib/src/utils/color.dart @@ -0,0 +1,125 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +Color stringToColor(String? s) { + switch (s) { + case 'transparent': + return Colors.transparent; + case 'black': + return Colors.black; + case 'black12': + return Colors.black12; + case 'black26': + return Colors.black26; + case 'black38': + return Colors.black38; + case 'black45': + return Colors.black45; + case 'black54': + return Colors.black54; + case 'black87': + return Colors.black87; + case 'white': + return Colors.white; + case 'white10': + return Colors.white10; + case 'white12': + return Colors.white12; + case 'white24': + return Colors.white24; + case 'white30': + return Colors.white30; + case 'white38': + return Colors.white38; + case 'white54': + return Colors.white54; + case 'white60': + return Colors.white60; + case 'white70': + return Colors.white70; + case 'red': + return Colors.red; + case 'redAccent': + return Colors.redAccent; + case 'amber': + return Colors.amber; + case 'amberAccent': + return Colors.amberAccent; + case 'yellow': + return Colors.yellow; + case 'yellowAccent': + return Colors.yellowAccent; + case 'teal': + return Colors.teal; + case 'tealAccent': + return Colors.tealAccent; + case 'purple': + return Colors.purple; + case 'purpleAccent': + return Colors.purpleAccent; + case 'pink': + return Colors.pink; + case 'pinkAccent': + return Colors.pinkAccent; + case 'orange': + return Colors.orange; + case 'orangeAccent': + return Colors.orangeAccent; + case 'deepOrange': + return Colors.deepOrange; + case 'deepOrangeAccent': + return Colors.deepOrangeAccent; + case 'indigo': + return Colors.indigo; + case 'indigoAccent': + return Colors.indigoAccent; + case 'lime': + return Colors.lime; + case 'limeAccent': + return Colors.limeAccent; + case 'grey': + return Colors.grey; + case 'blueGrey': + return Colors.blueGrey; + case 'green': + return Colors.green; + case 'greenAccent': + return Colors.greenAccent; + case 'lightGreen': + return Colors.lightGreen; + case 'lightGreenAccent': + return Colors.lightGreenAccent; + case 'blue': + return Colors.blue; + case 'blueAccent': + return Colors.blueAccent; + case 'lightBlue': + return Colors.lightBlue; + case 'lightBlueAccent': + return Colors.lightBlueAccent; + case 'cyan': + return Colors.cyan; + case 'cyanAccent': + return Colors.cyanAccent; + case 'brown': + return Colors.brown; + } + + if (s!.startsWith('rgba')) { + s = s.substring(5); // trim left 'rgba(' + s = s.substring(0, s.length - 1); // trim right ')' + final arr = s.split(',').map((e) => e.trim()).toList(); + return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]), + int.parse(arr[2]), double.parse(arr[3])); + } + + if (!s.startsWith('#')) { + throw 'Color code not supported'; + } + + var hex = s.replaceFirst('#', ''); + hex = hex.length == 6 ? 'ff$hex' : hex; + final val = int.parse(hex, radix: 16); + return Color(val); +} diff --git a/lib/src/utils/diff_delta.dart b/lib/src/utils/diff_delta.dart new file mode 100644 index 00000000..003bae47 --- /dev/null +++ b/lib/src/utils/diff_delta.dart @@ -0,0 +1,102 @@ +import 'dart:math' as math; + +import '../models/quill_delta.dart'; + +const Set WHITE_SPACE = { + 0x9, + 0xA, + 0xB, + 0xC, + 0xD, + 0x1C, + 0x1D, + 0x1E, + 0x1F, + 0x20, + 0xA0, + 0x1680, + 0x2000, + 0x2001, + 0x2002, + 0x2003, + 0x2004, + 0x2005, + 0x2006, + 0x2007, + 0x2008, + 0x2009, + 0x200A, + 0x202F, + 0x205F, + 0x3000 +}; + +// Diff between two texts - old text and new text +class Diff { + Diff(this.start, this.deleted, this.inserted); + + // Start index in old text at which changes begin. + final int start; + + /// The deleted text + final String deleted; + + // The inserted text + final String inserted; + + @override + String toString() { + return 'Diff[$start, "$deleted", "$inserted"]'; + } +} + +/* Get diff operation between old text and new text */ +Diff getDiff(String oldText, String newText, int cursorPosition) { + var end = oldText.length; + final delta = newText.length - end; + for (final limit = math.max(0, cursorPosition - delta); + end > limit && oldText[end - 1] == newText[end + delta - 1]; + end--) {} + var start = 0; + for (final startLimit = cursorPosition - math.max(0, delta); + start < startLimit && oldText[start] == newText[start]; + start++) {} + final deleted = (start >= end) ? '' : oldText.substring(start, end); + final inserted = newText.substring(start, end + delta); + return Diff(start, deleted, inserted); +} + +int getPositionDelta(Delta user, Delta actual) { + if (actual.isEmpty) { + return 0; + } + + final userItr = DeltaIterator(user); + final actualItr = DeltaIterator(actual); + var diff = 0; + while (userItr.hasNext || actualItr.hasNext) { + final length = math.min(userItr.peekLength(), actualItr.peekLength()); + final userOperation = userItr.next(length as int); + final actualOperation = actualItr.next(length); + if (userOperation.length != actualOperation.length) { + throw 'userOp ${userOperation.length} does not match actualOp ${actualOperation.length}'; + } + if (userOperation.key == actualOperation.key) { + continue; + } else if (userOperation.isInsert && actualOperation.isRetain) { + diff -= userOperation.length!; + } else if (userOperation.isDelete && actualOperation.isRetain) { + diff += userOperation.length!; + } else if (userOperation.isRetain && actualOperation.isInsert) { + String? operationTxt = ''; + if (actualOperation.data is String) { + operationTxt = actualOperation.data as String?; + } + if (operationTxt!.startsWith('\n')) { + continue; + } + diff += actualOperation.length!; + } + } + return diff; +} diff --git a/lib/src/widgets/box.dart b/lib/src/widgets/box.dart new file mode 100644 index 00000000..75547923 --- /dev/null +++ b/lib/src/widgets/box.dart @@ -0,0 +1,39 @@ +import 'package:flutter/rendering.dart'; + +import '../models/documents/nodes/container.dart'; + +abstract class RenderContentProxyBox implements RenderBox { + double getPreferredLineHeight(); + + Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype); + + TextPosition getPositionForOffset(Offset offset); + + double? getFullHeightForCaret(TextPosition position); + + TextRange getWordBoundary(TextPosition position); + + List getBoxesForSelection(TextSelection textSelection); +} + +abstract class RenderEditableBox extends RenderBox { + Container getContainer(); + + double preferredLineHeight(TextPosition position); + + Offset getOffsetForCaret(TextPosition position); + + TextPosition getPositionForOffset(Offset offset); + + TextPosition? getPositionAbove(TextPosition position); + + TextPosition? getPositionBelow(TextPosition position); + + TextRange getWordBoundary(TextPosition position); + + TextRange getLineBoundary(TextPosition position); + + TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection); + + TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection); +} diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart new file mode 100644 index 00000000..bd669171 --- /dev/null +++ b/lib/src/widgets/controller.dart @@ -0,0 +1,229 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:tuple/tuple.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/document.dart'; +import '../models/documents/nodes/embed.dart'; +import '../models/documents/style.dart'; +import '../models/quill_delta.dart'; +import '../utils/diff_delta.dart'; + +class QuillController extends ChangeNotifier { + QuillController( + {required this.document, + required this.selection, + this.iconSize = 18, + this.toolbarHeightFactor = 2}); + + factory QuillController.basic() { + return QuillController( + document: Document(), + selection: const TextSelection.collapsed(offset: 0), + ); + } + + final Document document; + TextSelection selection; + double iconSize; + double toolbarHeightFactor; + + Style toggledStyle = Style(); + bool ignoreFocusOnTextChange = false; + + /// Controls whether this [QuillController] instance has already been disposed + /// of + /// + /// This is a safe approach to make sure that listeners don't crash when + /// adding, removing or listeners to this instance. + bool _isDisposed = false; + + // item1: Document state before [change]. + // + // item2: Change delta applied to the document. + // + // item3: The source of this change. + Stream> get changes => document.changes; + + TextEditingValue get plainTextEditingValue => TextEditingValue( + text: document.toPlainText(), + selection: selection, + ); + + Style getSelectionStyle() { + return document + .collectStyle(selection.start, selection.end - selection.start) + .mergeAll(toggledStyle); + } + + void undo() { + final tup = document.undo(); + if (tup.item1) { + _handleHistoryChange(tup.item2); + } + } + + void _handleHistoryChange(int? len) { + if (len != 0) { + // if (this.selection.extentOffset >= document.length) { + // // cursor exceeds the length of document, position it in the end + // updateSelection( + // TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL); + updateSelection( + TextSelection.collapsed(offset: selection.baseOffset + len!), + ChangeSource.LOCAL); + } else { + // no need to move cursor + notifyListeners(); + } + } + + void redo() { + final tup = document.redo(); + if (tup.item1) { + _handleHistoryChange(tup.item2); + } + } + + bool get hasUndo => document.hasUndo; + + bool get hasRedo => document.hasRedo; + + void replaceText( + int index, int len, Object? data, TextSelection? textSelection, + {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, + autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); + var shouldRetainDelta = toggledStyle.isNotEmpty && + delta.isNotEmpty && + delta.length <= 2 && + delta.last.isInsert; + if (shouldRetainDelta && + toggledStyle.isNotEmpty && + delta.length == 2 && + delta.last.data == '\n') { + // if all attributes are inline, shouldRetainDelta should be false + final anyAttributeNotInline = + toggledStyle.values.any((attr) => !attr.isInline); + if (!anyAttributeNotInline) { + shouldRetainDelta = false; + } + } + if (shouldRetainDelta) { + final 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 || delta.isEmpty) { + _updateSelection(textSelection, ChangeSource.LOCAL); + } else { + final user = Delta() + ..retain(index) + ..insert(data) + ..delete(len); + final positionDelta = getPositionDelta(user, delta); + _updateSelection( + textSelection.copyWith( + baseOffset: textSelection.baseOffset + positionDelta, + extentOffset: textSelection.extentOffset + positionDelta, + ), + ChangeSource.LOCAL, + ); + } + } + + if (ignoreFocus) { + ignoreFocusOnTextChange = true; + } + notifyListeners(); + ignoreFocusOnTextChange = false; + } + + void formatText(int index, int len, Attribute? attribute) { + if (len == 0 && + attribute!.isInline && + attribute.key != Attribute.link.key) { + toggledStyle = toggledStyle.put(attribute); + } + + final change = document.format(index, len, attribute); + final adjustedSelection = selection.copyWith( + baseOffset: change.transformPosition(selection.baseOffset), + extentOffset: change.transformPosition(selection.extentOffset)); + if (selection != adjustedSelection) { + _updateSelection(adjustedSelection, ChangeSource.LOCAL); + } + notifyListeners(); + } + + void formatSelection(Attribute? attribute) { + formatText(selection.start, selection.end - selection.start, attribute); + } + + void updateSelection(TextSelection textSelection, ChangeSource source) { + _updateSelection(textSelection, source); + notifyListeners(); + } + + void compose(Delta delta, TextSelection textSelection, ChangeSource source) { + if (delta.isNotEmpty) { + document.compose(delta, source); + } + + 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 addListener(VoidCallback listener) { + // By using `_isDisposed`, make sure that `addListener` won't be called on a + // disposed `ChangeListener` + if (!_isDisposed) { + super.addListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + // By using `_isDisposed`, make sure that `removeListener` won't be called + // on a disposed `ChangeListener` + if (!_isDisposed) { + super.removeListener(listener); + } + } + + @override + void dispose() { + if (!_isDisposed) { + document.close(); + } + + _isDisposed = true; + super.dispose(); + } + + void _updateSelection(TextSelection textSelection, ChangeSource source) { + selection = textSelection; + final end = document.length - 1; + selection = selection.copyWith( + baseOffset: math.min(selection.baseOffset, end), + extentOffset: math.min(selection.extentOffset, end)); + } +} diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart new file mode 100644 index 00000000..963bc2e7 --- /dev/null +++ b/lib/src/widgets/cursor.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'box.dart'; + +const Duration _FADE_DURATION = Duration(milliseconds: 250); + +class CursorStyle { + const CursorStyle({ + required this.color, + required this.backgroundColor, + this.width = 1.0, + this.height, + this.radius, + this.offset, + this.opacityAnimates = false, + this.paintAboveText = false, + }); + + final Color color; + final Color backgroundColor; + final double width; + final double? height; + final Radius? radius; + final Offset? offset; + final bool opacityAnimates; + final bool paintAboveText; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CursorStyle && + runtimeType == other.runtimeType && + color == other.color && + backgroundColor == other.backgroundColor && + width == other.width && + height == other.height && + radius == other.radius && + offset == other.offset && + opacityAnimates == other.opacityAnimates && + paintAboveText == other.paintAboveText; + + @override + int get hashCode => + color.hashCode ^ + backgroundColor.hashCode ^ + width.hashCode ^ + height.hashCode ^ + radius.hashCode ^ + offset.hashCode ^ + opacityAnimates.hashCode ^ + paintAboveText.hashCode; +} + +class CursorCont extends ChangeNotifier { + CursorCont({ + required this.show, + required CursorStyle style, + required TickerProvider tickerProvider, + }) : _style = style, + _blink = ValueNotifier(false), + color = ValueNotifier(style.color) { + _blinkOpacityCont = + AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); + _blinkOpacityCont.addListener(_onColorTick); + } + + final ValueNotifier show; + final ValueNotifier _blink; + final ValueNotifier color; + late AnimationController _blinkOpacityCont; + Timer? _cursorTimer; + bool _targetCursorVisibility = false; + CursorStyle _style; + + ValueNotifier get cursorBlink => _blink; + + ValueNotifier get cursorColor => color; + + CursorStyle get style => _style; + + set style(CursorStyle value) { + if (_style == value) return; + _style = value; + notifyListeners(); + } + + @override + void dispose() { + _blinkOpacityCont.removeListener(_onColorTick); + stopCursorTimer(); + _blinkOpacityCont.dispose(); + assert(_cursorTimer == null); + super.dispose(); + } + + void _cursorTick(Timer timer) { + _targetCursorVisibility = !_targetCursorVisibility; + final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; + if (style.opacityAnimates) { + _blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); + } else { + _blinkOpacityCont.value = targetOpacity; + } + } + + void _cursorWaitForStart(Timer timer) { + _cursorTimer?.cancel(); + _cursorTimer = + Timer.periodic(const Duration(milliseconds: 500), _cursorTick); + } + + void startCursorTimer() { + _targetCursorVisibility = true; + _blinkOpacityCont.value = 1.0; + + if (style.opacityAnimates) { + _cursorTimer = Timer.periodic( + const Duration(milliseconds: 150), _cursorWaitForStart); + } else { + _cursorTimer = + Timer.periodic(const Duration(milliseconds: 500), _cursorTick); + } + } + + void stopCursorTimer({bool resetCharTicks = true}) { + _cursorTimer?.cancel(); + _cursorTimer = null; + _targetCursorVisibility = false; + _blinkOpacityCont.value = 0.0; + + if (style.opacityAnimates) { + _blinkOpacityCont + ..stop() + ..value = 0.0; + } + } + + void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) { + if (show.value && + _cursorTimer == null && + hasFocus && + selection.isCollapsed) { + startCursorTimer(); + } else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) { + stopCursorTimer(); + } + } + + void _onColorTick() { + color.value = _style.color.withOpacity(_blinkOpacityCont.value); + _blink.value = show.value && _blinkOpacityCont.value > 0; + } +} + +class CursorPainter { + CursorPainter(this.editable, this.style, this.prototype, this.color, + this.devicePixelRatio); + + final RenderContentProxyBox? editable; + final CursorStyle style; + final Rect? prototype; + final Color color; + final double devicePixelRatio; + + void paint(Canvas canvas, Offset offset, TextPosition position) { + assert(prototype != null); + + final caretOffset = + editable!.getOffsetForCaret(position, prototype) + offset; + var caretRect = prototype!.shift(caretOffset); + if (style.offset != null) { + caretRect = caretRect.shift(style.offset!); + } + + if (caretRect.left < 0.0) { + caretRect = caretRect.shift(Offset(-caretRect.left, 0)); + } + + final caretHeight = editable!.getFullHeightForCaret(position); + if (caretHeight != null) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top - 2.0, + caretRect.width, + caretHeight, + ); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top + (caretHeight - caretRect.height) / 2, + caretRect.width, + caretRect.height, + ); + break; + default: + throw UnimplementedError(); + } + } + + final caretPosition = editable!.localToGlobal(caretRect.topLeft); + final pixelMultiple = 1.0 / devicePixelRatio; + caretRect = caretRect.shift(Offset( + caretPosition.dx.isFinite + ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - + caretPosition.dx + : caretPosition.dx, + caretPosition.dy.isFinite + ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - + caretPosition.dy + : caretPosition.dy)); + + final paint = Paint()..color = color; + if (style.radius == null) { + canvas.drawRect(caretRect, paint); + return; + } + + final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); + canvas.drawRRect(caretRRect, paint); + } +} diff --git a/lib/src/widgets/default_styles.dart b/lib/src/widgets/default_styles.dart new file mode 100644 index 00000000..1cebe135 --- /dev/null +++ b/lib/src/widgets/default_styles.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:tuple/tuple.dart'; + +class QuillStyles extends InheritedWidget { + const QuillStyles({ + required this.data, + required Widget child, + Key? key, + }) : super(key: key, child: child); + + final DefaultStyles data; + + @override + bool updateShouldNotify(QuillStyles oldWidget) { + return data != oldWidget.data; + } + + static DefaultStyles? getStyles(BuildContext context, bool nullOk) { + final widget = context.dependOnInheritedWidgetOfExactType(); + if (widget == null && nullOk) { + return null; + } + assert(widget != null); + return widget!.data; + } +} + +class DefaultTextBlockStyle { + DefaultTextBlockStyle( + this.style, + this.verticalSpacing, + this.lineSpacing, + this.decoration, + ); + + final TextStyle style; + + final Tuple2 verticalSpacing; + + final Tuple2 lineSpacing; + + final BoxDecoration? decoration; +} + +class DefaultStyles { + DefaultStyles({ + this.h1, + this.h2, + this.h3, + this.paragraph, + this.bold, + this.italic, + this.underline, + this.strikeThrough, + this.link, + this.color, + this.placeHolder, + this.lists, + this.quote, + this.code, + this.indent, + this.align, + this.leading, + this.sizeSmall, + this.sizeLarge, + this.sizeHuge, + }); + + final DefaultTextBlockStyle? h1; + final DefaultTextBlockStyle? h2; + final DefaultTextBlockStyle? h3; + final DefaultTextBlockStyle? paragraph; + final TextStyle? bold; + final TextStyle? italic; + final TextStyle? underline; + final TextStyle? strikeThrough; + final TextStyle? sizeSmall; // 'small' + final TextStyle? sizeLarge; // 'large' + final TextStyle? sizeHuge; // 'huge' + final TextStyle? link; + final Color? color; + final DefaultTextBlockStyle? placeHolder; + final DefaultTextBlockStyle? lists; + final DefaultTextBlockStyle? quote; + final DefaultTextBlockStyle? code; + final DefaultTextBlockStyle? indent; + final DefaultTextBlockStyle? align; + final DefaultTextBlockStyle? leading; + + static DefaultStyles getInstance(BuildContext context) { + final themeData = Theme.of(context); + final defaultTextStyle = DefaultTextStyle.of(context); + final baseStyle = defaultTextStyle.style.copyWith( + fontSize: 16, + height: 1.3, + ); + const baseSpacing = Tuple2(6, 0); + String fontFamily; + switch (themeData.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + fontFamily = 'Menlo'; + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + case TargetPlatform.linux: + fontFamily = 'Roboto Mono'; + break; + default: + throw UnimplementedError(); + } + + return DefaultStyles( + h1: DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 34, + color: defaultTextStyle.style.color!.withOpacity(0.70), + height: 1.15, + fontWeight: FontWeight.w300, + ), + const Tuple2(16, 0), + const Tuple2(0, 0), + null), + h2: DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 24, + color: defaultTextStyle.style.color!.withOpacity(0.70), + height: 1.15, + fontWeight: FontWeight.normal, + ), + const Tuple2(8, 0), + const Tuple2(0, 0), + null), + h3: DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 20, + color: defaultTextStyle.style.color!.withOpacity(0.70), + height: 1.25, + fontWeight: FontWeight.w500, + ), + const Tuple2(8, 0), + const Tuple2(0, 0), + null), + paragraph: DefaultTextBlockStyle( + baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), + bold: const TextStyle(fontWeight: FontWeight.bold), + italic: const TextStyle(fontStyle: FontStyle.italic), + underline: const TextStyle(decoration: TextDecoration.underline), + strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough), + link: TextStyle( + color: themeData.accentColor, + decoration: TextDecoration.underline, + ), + placeHolder: DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 20, + height: 1.5, + color: Colors.grey.withOpacity(0.6), + ), + const Tuple2(0, 0), + const Tuple2(0, 0), + null), + lists: DefaultTextBlockStyle( + baseStyle, baseSpacing, const Tuple2(0, 6), null), + quote: DefaultTextBlockStyle( + TextStyle(color: baseStyle.color!.withOpacity(0.6)), + baseSpacing, + const Tuple2(6, 2), + BoxDecoration( + border: Border( + left: BorderSide(width: 4, color: Colors.grey.shade300), + ), + )), + code: DefaultTextBlockStyle( + TextStyle( + color: Colors.blue.shade900.withOpacity(0.9), + fontFamily: fontFamily, + fontSize: 13, + height: 1.15, + ), + baseSpacing, + const Tuple2(0, 0), + BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(2), + )), + indent: DefaultTextBlockStyle( + baseStyle, baseSpacing, const Tuple2(0, 6), null), + align: DefaultTextBlockStyle( + baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), + leading: DefaultTextBlockStyle( + baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), + sizeSmall: const TextStyle(fontSize: 10), + sizeLarge: const TextStyle(fontSize: 18), + sizeHuge: const TextStyle(fontSize: 22)); + } + + DefaultStyles merge(DefaultStyles other) { + return DefaultStyles( + h1: other.h1 ?? h1, + h2: other.h2 ?? h2, + h3: other.h3 ?? h3, + paragraph: other.paragraph ?? paragraph, + bold: other.bold ?? bold, + italic: other.italic ?? italic, + underline: other.underline ?? underline, + strikeThrough: other.strikeThrough ?? strikeThrough, + link: other.link ?? link, + color: other.color ?? color, + placeHolder: other.placeHolder ?? placeHolder, + lists: other.lists ?? lists, + quote: other.quote ?? quote, + code: other.code ?? code, + indent: other.indent ?? indent, + align: other.align ?? align, + leading: other.leading ?? leading, + sizeSmall: other.sizeSmall ?? sizeSmall, + sizeLarge: other.sizeLarge ?? sizeLarge, + sizeHuge: other.sizeHuge ?? sizeHuge); + } +} diff --git a/lib/src/widgets/delegate.dart b/lib/src/widgets/delegate.dart new file mode 100644 index 00000000..4b4bdea7 --- /dev/null +++ b/lib/src/widgets/delegate.dart @@ -0,0 +1,148 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../models/documents/nodes/leaf.dart'; +import 'editor.dart'; +import 'text_selection.dart'; + +typedef EmbedBuilder = Widget Function(BuildContext context, Embed node); + +abstract class EditorTextSelectionGestureDetectorBuilderDelegate { + GlobalKey getEditableTextKey(); + + bool getForcePressEnabled(); + + bool getSelectionEnabled(); +} + +class EditorTextSelectionGestureDetectorBuilder { + EditorTextSelectionGestureDetectorBuilder(this.delegate); + + final EditorTextSelectionGestureDetectorBuilderDelegate delegate; + bool shouldShowSelectionToolbar = true; + + EditorState? getEditor() { + return delegate.getEditableTextKey().currentState; + } + + RenderEditor? getRenderEditor() { + return getEditor()!.getRenderEditor(); + } + + void onTapDown(TapDownDetails details) { + getRenderEditor()!.handleTapDown(details); + + final kind = details.kind; + shouldShowSelectionToolbar = kind == null || + kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus; + } + + void onForcePressStart(ForcePressDetails details) { + assert(delegate.getForcePressEnabled()); + shouldShowSelectionToolbar = true; + if (delegate.getSelectionEnabled()) { + getRenderEditor()!.selectWordsInRange( + details.globalPosition, + null, + SelectionChangedCause.forcePress, + ); + } + } + + void onForcePressEnd(ForcePressDetails details) { + assert(delegate.getForcePressEnabled()); + getRenderEditor()!.selectWordsInRange( + details.globalPosition, + null, + SelectionChangedCause.forcePress, + ); + if (shouldShowSelectionToolbar) { + getEditor()!.showToolbar(); + } + } + + void onSingleTapUp(TapUpDetails details) { + if (delegate.getSelectionEnabled()) { + getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); + } + } + + void onSingleTapCancel() {} + + void onSingleLongTapStart(LongPressStartDetails details) { + if (delegate.getSelectionEnabled()) { + getRenderEditor()!.selectPositionAt( + details.globalPosition, + null, + SelectionChangedCause.longPress, + ); + } + } + + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (delegate.getSelectionEnabled()) { + getRenderEditor()!.selectPositionAt( + details.globalPosition, + null, + SelectionChangedCause.longPress, + ); + } + } + + void onSingleLongTapEnd(LongPressEndDetails details) { + if (shouldShowSelectionToolbar) { + getEditor()!.showToolbar(); + } + } + + void onDoubleTapDown(TapDownDetails details) { + if (delegate.getSelectionEnabled()) { + getRenderEditor()!.selectWord(SelectionChangedCause.tap); + if (shouldShowSelectionToolbar) { + getEditor()!.showToolbar(); + } + } + } + + void onDragSelectionStart(DragStartDetails details) { + getRenderEditor()!.selectPositionAt( + details.globalPosition, + null, + SelectionChangedCause.drag, + ); + } + + void onDragSelectionUpdate( + DragStartDetails startDetails, DragUpdateDetails updateDetails) { + getRenderEditor()!.selectPositionAt( + startDetails.globalPosition, + updateDetails.globalPosition, + SelectionChangedCause.drag, + ); + } + + void onDragSelectionEnd(DragEndDetails details) {} + + Widget build(HitTestBehavior behavior, Widget child) { + return EditorTextSelectionGestureDetector( + onTapDown: onTapDown, + onForcePressStart: + delegate.getForcePressEnabled() ? onForcePressStart : null, + onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null, + onSingleTapUp: onSingleTapUp, + onSingleTapCancel: onSingleTapCancel, + onSingleLongTapStart: onSingleLongTapStart, + onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, + onSingleLongTapEnd: onSingleLongTapEnd, + onDoubleTapDown: onDoubleTapDown, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + behavior: behavior, + child: child, + ); + } +} diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart new file mode 100644 index 00000000..662a018c --- /dev/null +++ b/lib/src/widgets/editor.dart @@ -0,0 +1,1145 @@ +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.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:url_launcher/url_launcher.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/document.dart'; +import '../models/documents/nodes/container.dart' as container_node; +import '../models/documents/nodes/embed.dart'; +import '../models/documents/nodes/leaf.dart' as leaf; +import '../models/documents/nodes/line.dart'; +import 'box.dart'; +import 'controller.dart'; +import 'cursor.dart'; +import 'default_styles.dart'; +import 'delegate.dart'; +import 'image.dart'; +import 'raw_editor.dart'; +import 'text_selection.dart'; + +const linkPrefixes = [ + 'mailto:', // email + 'tel:', // telephone + 'sms:', // SMS + 'callto:', + 'wtai:', + 'market:', + 'geopoint:', + 'ymsgr:', + 'msnim:', + 'gtalk:', // Google Talk + 'skype:', + 'sip:', // Lync + 'whatsapp:', + 'http' +]; + +abstract class EditorState extends State { + TextEditingValue getTextEditingValue(); + + void setTextEditingValue(TextEditingValue value); + + RenderEditor? getRenderEditor(); + + EditorTextSelectionOverlay? getSelectionOverlay(); + + bool showToolbar(); + + void hideToolbar(); + + void requestKeyboard(); +} + +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); +} + +String _standardizeImageUrl(String url) { + if (url.contains('base64')) { + return url.split(',')[1]; + } + return url; +} + +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.'); + } +} + +class QuillEditor extends StatefulWidget { + const QuillEditor( + {required this.controller, + required this.focusNode, + required this.scrollController, + required this.scrollable, + required this.padding, + required this.autoFocus, + required this.readOnly, + required this.expands, + this.showCursor, + this.placeholder, + this.enableInteractiveSelection = true, + this.scrollBottomInset = 0, + this.minHeight, + this.maxHeight, + this.customStyles, + this.textCapitalization = TextCapitalization.sentences, + this.keyboardAppearance = Brightness.light, + this.scrollPhysics, + this.onLaunchUrl, + this.onTapDown, + this.onTapUp, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.embedBuilder = _defaultEmbedBuilder}); + + factory QuillEditor.basic({ + required QuillController controller, + required bool readOnly, + }) { + return QuillEditor( + controller: controller, + scrollController: ScrollController(), + scrollable: true, + focusNode: FocusNode(), + autoFocus: true, + readOnly: readOnly, + expands: false, + padding: EdgeInsets.zero); + } + + final QuillController controller; + final FocusNode focusNode; + final ScrollController scrollController; + final bool scrollable; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + final bool autoFocus; + final bool? showCursor; + final bool readOnly; + final String? placeholder; + final bool enableInteractiveSelection; + final double? minHeight; + final double? maxHeight; + final DefaultStyles? customStyles; + final bool expands; + final TextCapitalization textCapitalization; + final Brightness keyboardAppearance; + final ScrollPhysics? scrollPhysics; + final ValueChanged? onLaunchUrl; + // Returns whether gesture is handled + final bool Function( + TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; + + // Returns whether gesture is handled + final bool Function( + TapUpDetails details, TextPosition Function(Offset offset))? onTapUp; + + // Returns whether gesture is handled + final bool Function( + LongPressStartDetails details, TextPosition Function(Offset offset))? + onSingleLongTapStart; + + // Returns whether gesture is handled + final bool Function(LongPressMoveUpdateDetails details, + TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate; + // Returns whether gesture is handled + final bool Function( + LongPressEndDetails details, TextPosition Function(Offset offset))? + onSingleLongTapEnd; + + final EmbedBuilder embedBuilder; + + @override + _QuillEditorState createState() => _QuillEditorState(); +} + +class _QuillEditorState extends State + implements EditorTextSelectionGestureDetectorBuilderDelegate { + final GlobalKey _editorKey = GlobalKey(); + late EditorTextSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = + _QuillEditorSelectionGestureDetectorBuilder(this); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final selectionTheme = TextSelectionTheme.of(context); + + TextSelectionControls textSelectionControls; + bool paintCursorAboveText; + bool cursorOpacityAnimates; + Offset? cursorOffset; + Color? cursorColor; + Color selectionColor; + Radius? cursorRadius; + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + textSelectionControls = materialTextSelectionControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final cupertinoTheme = CupertinoTheme.of(context); + textSelectionControls = cupertinoTextSelectionControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor ??= + selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = selectionTheme.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2); + cursorOffset = Offset( + iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + break; + default: + throw UnimplementedError(); + } + + return _selectionGestureDetectorBuilder.build( + HitTestBehavior.translucent, + RawEditor( + _editorKey, + widget.controller, + widget.focusNode, + widget.scrollController, + widget.scrollable, + widget.scrollBottomInset, + widget.padding, + widget.readOnly, + widget.placeholder, + widget.onLaunchUrl, + ToolbarOptions( + copy: widget.enableInteractiveSelection, + cut: widget.enableInteractiveSelection, + paste: widget.enableInteractiveSelection, + selectAll: widget.enableInteractiveSelection, + ), + theme.platform == TargetPlatform.iOS || + theme.platform == TargetPlatform.android, + widget.showCursor, + CursorStyle( + color: cursorColor, + backgroundColor: Colors.grey, + width: 2, + radius: cursorRadius, + offset: cursorOffset, + paintAboveText: paintCursorAboveText, + opacityAnimates: cursorOpacityAnimates, + ), + widget.textCapitalization, + widget.maxHeight, + widget.minHeight, + widget.customStyles, + widget.expands, + widget.autoFocus, + selectionColor, + textSelectionControls, + widget.keyboardAppearance, + widget.enableInteractiveSelection, + widget.scrollPhysics, + widget.embedBuilder), + ); + } + + @override + GlobalKey getEditableTextKey() { + return _editorKey; + } + + @override + bool getForcePressEnabled() { + return false; + } + + @override + bool getSelectionEnabled() { + return widget.enableInteractiveSelection; + } + + void _requestKeyboard() { + _editorKey.currentState!.requestKeyboard(); + } +} + +class _QuillEditorSelectionGestureDetectorBuilder + extends EditorTextSelectionGestureDetectorBuilder { + _QuillEditorSelectionGestureDetectorBuilder(this._state) : super(_state); + + final _QuillEditorState _state; + + @override + void onForcePressStart(ForcePressDetails details) { + super.onForcePressStart(details); + if (delegate.getSelectionEnabled() && shouldShowSelectionToolbar) { + getEditor()!.showToolbar(); + } + } + + @override + void onForcePressEnd(ForcePressDetails details) {} + + @override + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (_state.widget.onSingleLongTapMoveUpdate != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onSingleLongTapMoveUpdate!( + details, renderEditor.getPositionForOffset)) { + return; + } + } + } + if (!delegate.getSelectionEnabled()) { + return; + } + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + getRenderEditor()!.selectPositionAt( + details.globalPosition, + null, + SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + getRenderEditor()!.selectWordsInRange( + details.globalPosition - details.offsetFromOrigin, + details.globalPosition, + SelectionChangedCause.longPress, + ); + break; + default: + throw 'Invalid platform'; + } + } + + bool _onTapping(TapUpDetails details) { + if (_state.widget.controller.document.isEmpty()) { + return false; + } + final pos = getRenderEditor()!.getPositionForOffset(details.globalPosition); + final result = + getEditor()!.widget.controller.document.queryChild(pos.offset); + if (result.node == null) { + return false; + } + final line = result.node as Line; + final segmentResult = line.queryChild(result.offset, false); + if (segmentResult.node == null) { + if (line.length == 1) { + getEditor()!.widget.controller.updateSelection( + TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); + return true; + } + return false; + } + final segment = segmentResult.node as leaf.Leaf; + if (segment.style.containsKey(Attribute.link.key)) { + var launchUrl = getEditor()!.widget.onLaunchUrl; + launchUrl ??= _launchUrl; + String? link = segment.style.attributes[Attribute.link.key]!.value; + if (getEditor()!.widget.readOnly && link != null) { + link = link.trim(); + if (!linkPrefixes + .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { + link = 'https://$link'; + } + launchUrl(link); + } + return false; + } + if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) { + final blockEmbed = segment.value as BlockEmbed; + if (blockEmbed.type == 'image') { + final imageUrl = _standardizeImageUrl(blockEmbed.data); + Navigator.push( + getEditor()!.context, + MaterialPageRoute( + builder: (context) => ImageTapWrapper( + imageProvider: imageUrl.startsWith('http') + ? NetworkImage(imageUrl) + : isBase64(imageUrl) + ? Image.memory(base64.decode(imageUrl)) + as ImageProvider? + : FileImage(io.File(imageUrl)), + ), + ), + ); + } + } + + return false; + } + + Future _launchUrl(String url) async { + 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)) { + 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)) { + return; + } + } + } + + getEditor()!.hideToolbar(); + + final positionSelected = _onTapping(details); + + if (delegate.getSelectionEnabled() && !positionSelected) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + getRenderEditor()!.selectPosition(SelectionChangedCause.tap); + break; + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); + break; + } + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + getRenderEditor()!.selectPosition(SelectionChangedCause.tap); + break; + } + } + _state._requestKeyboard(); + } + + @override + void onSingleLongTapStart(LongPressStartDetails details) { + if (_state.widget.onSingleLongTapStart != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onSingleLongTapStart!( + details, renderEditor.getPositionForOffset)) { + return; + } + } + } + + if (delegate.getSelectionEnabled()) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + getRenderEditor()!.selectPositionAt( + details.globalPosition, + null, + SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + getRenderEditor()!.selectWord(SelectionChangedCause.longPress); + Feedback.forLongPress(_state.context); + break; + default: + throw 'Invalid platform'; + } + } + } + + @override + void onSingleLongTapEnd(LongPressEndDetails details) { + if (_state.widget.onSingleLongTapEnd != null) { + final renderEditor = getRenderEditor(); + if (renderEditor != null) { + if (_state.widget.onSingleLongTapEnd!( + details, renderEditor.getPositionForOffset)) { + return; + } + } + } + super.onSingleLongTapEnd(details); + } +} + +typedef TextSelectionChangedHandler = void Function( + TextSelection selection, SelectionChangedCause cause); + +class RenderEditor extends RenderEditableContainerBox + implements RenderAbstractEditor { + RenderEditor( + List? children, + TextDirection textDirection, + double scrollBottomInset, + EdgeInsetsGeometry padding, + this.document, + this.selection, + this._hasFocus, + this.onSelectionChanged, + this._startHandleLayerLink, + this._endHandleLayerLink, + EdgeInsets floatingCursorAddedMargin, + ) : super( + children, + document.root, + textDirection, + scrollBottomInset, + padding, + ); + + Document document; + TextSelection selection; + bool _hasFocus = false; + LayerLink _startHandleLayerLink; + LayerLink _endHandleLayerLink; + TextSelectionChangedHandler onSelectionChanged; + final ValueNotifier _selectionStartInViewport = + ValueNotifier(true); + + ValueListenable get selectionStartInViewport => + _selectionStartInViewport; + + ValueListenable get selectionEndInViewport => _selectionEndInViewport; + final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + + void setDocument(Document doc) { + if (document == doc) { + return; + } + document = doc; + markNeedsLayout(); + } + + void setHasFocus(bool h) { + if (_hasFocus == h) { + return; + } + _hasFocus = h; + markNeedsSemanticsUpdate(); + } + + void setSelection(TextSelection t) { + if (selection == t) { + return; + } + selection = t; + markNeedsPaint(); + } + + void setStartHandleLayerLink(LayerLink value) { + if (_startHandleLayerLink == value) { + return; + } + _startHandleLayerLink = value; + markNeedsPaint(); + } + + void setEndHandleLayerLink(LayerLink value) { + if (_endHandleLayerLink == value) { + return; + } + _endHandleLayerLink = value; + markNeedsPaint(); + } + + void setScrollBottomInset(double value) { + if (scrollBottomInset == value) { + return; + } + scrollBottomInset = value; + markNeedsPaint(); + } + + @override + List getEndpointsForSelection( + TextSelection textSelection) { + if (textSelection.isCollapsed) { + final child = childAtPosition(textSelection.extent); + final localPosition = TextPosition( + offset: textSelection.extentOffset - child.getContainer().offset); + final localOffset = child.getOffsetForCaret(localPosition); + final parentData = child.parentData as BoxParentData; + return [ + TextSelectionPoint( + Offset(0, child.preferredLineHeight(localPosition)) + + localOffset + + parentData.offset, + null) + ]; + } + + final baseNode = _container.queryChild(textSelection.start, false).node; + + var baseChild = firstChild; + while (baseChild != null) { + if (baseChild.getContainer() == baseNode) { + break; + } + baseChild = childAfter(baseChild); + } + assert(baseChild != null); + + final baseParentData = baseChild!.parentData as BoxParentData; + final baseSelection = + localSelection(baseChild.getContainer(), textSelection, true); + var basePoint = baseChild.getBaseEndpointForSelection(baseSelection); + basePoint = TextSelectionPoint( + basePoint.point + baseParentData.offset, basePoint.direction); + + final extentNode = _container.queryChild(textSelection.end, false).node; + RenderEditableBox? extentChild = baseChild; + while (extentChild != null) { + if (extentChild.getContainer() == extentNode) { + break; + } + extentChild = childAfter(extentChild); + } + assert(extentChild != null); + + final extentParentData = extentChild!.parentData as BoxParentData; + final extentSelection = + localSelection(extentChild.getContainer(), textSelection, true); + var extentPoint = + extentChild.getExtentEndpointForSelection(extentSelection); + extentPoint = TextSelectionPoint( + extentPoint.point + extentParentData.offset, extentPoint.direction); + + return [basePoint, extentPoint]; + } + + Offset? _lastTapDownPosition; + + @override + void handleTapDown(TapDownDetails details) { + _lastTapDownPosition = details.globalPosition; + } + + @override + void selectWordsInRange( + Offset from, + Offset? to, + SelectionChangedCause cause, + ) { + final firstPosition = getPositionForOffset(from); + final firstWord = selectWordAtPosition(firstPosition); + final lastWord = + to == null ? firstWord : selectWordAtPosition(getPositionForOffset(to)); + + _handleSelectionChange( + TextSelection( + baseOffset: firstWord.base.offset, + extentOffset: lastWord.extent.offset, + affinity: firstWord.affinity, + ), + cause, + ); + } + + void _handleSelectionChange( + TextSelection nextSelection, + SelectionChangedCause cause, + ) { + final focusingEmpty = nextSelection.baseOffset == 0 && + nextSelection.extentOffset == 0 && + !_hasFocus; + if (nextSelection == selection && + cause != SelectionChangedCause.keyboard && + !focusingEmpty) { + return; + } + onSelectionChanged(nextSelection, cause); + } + + @override + void selectWordEdge(SelectionChangedCause cause) { + assert(_lastTapDownPosition != null); + final position = getPositionForOffset(_lastTapDownPosition!); + final child = childAtPosition(position); + final nodeOffset = child.getContainer().offset; + final localPosition = TextPosition( + offset: position.offset - nodeOffset, + affinity: position.affinity, + ); + final localWord = child.getWordBoundary(localPosition); + final word = TextRange( + start: localWord.start + nodeOffset, + end: localWord.end + nodeOffset, + ); + if (position.offset - word.start <= 1) { + _handleSelectionChange( + TextSelection.collapsed(offset: word.start), + cause, + ); + } else { + _handleSelectionChange( + TextSelection.collapsed( + offset: word.end, affinity: TextAffinity.upstream), + cause, + ); + } + } + + @override + void selectPositionAt( + Offset from, + Offset? to, + SelectionChangedCause cause, + ) { + final fromPosition = getPositionForOffset(from); + final toPosition = to == null ? null : getPositionForOffset(to); + + var baseOffset = fromPosition.offset; + var extentOffset = fromPosition.offset; + if (toPosition != null) { + baseOffset = math.min(fromPosition.offset, toPosition.offset); + extentOffset = math.max(fromPosition.offset, toPosition.offset); + } + + final newSelection = TextSelection( + baseOffset: baseOffset, + extentOffset: extentOffset, + affinity: fromPosition.affinity, + ); + _handleSelectionChange(newSelection, cause); + } + + @override + void selectWord(SelectionChangedCause cause) { + selectWordsInRange(_lastTapDownPosition!, null, cause); + } + + @override + void selectPosition(SelectionChangedCause cause) { + selectPositionAt(_lastTapDownPosition!, null, cause); + } + + @override + TextSelection selectWordAtPosition(TextPosition position) { + final child = childAtPosition(position); + final nodeOffset = child.getContainer().offset; + final localPosition = TextPosition( + offset: position.offset - nodeOffset, affinity: position.affinity); + final localWord = child.getWordBoundary(localPosition); + final word = TextRange( + start: localWord.start + nodeOffset, + end: localWord.end + nodeOffset, + ); + if (position.offset >= word.end) { + return TextSelection.fromPosition(position); + } + return TextSelection(baseOffset: word.start, extentOffset: word.end); + } + + @override + TextSelection selectLineAtPosition(TextPosition position) { + final child = childAtPosition(position); + final nodeOffset = child.getContainer().offset; + final localPosition = TextPosition( + offset: position.offset - nodeOffset, affinity: position.affinity); + final localLineRange = child.getLineBoundary(localPosition); + final line = TextRange( + start: localLineRange.start + nodeOffset, + end: localLineRange.end + nodeOffset, + ); + + if (position.offset >= line.end) { + return TextSelection.fromPosition(position); + } + return TextSelection(baseOffset: line.start, extentOffset: line.end); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + _paintHandleLayers(context, getEndpointsForSelection(selection)); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + void _paintHandleLayers( + PaintingContext context, List endpoints) { + var startPoint = endpoints[0].point; + startPoint = Offset( + startPoint.dx.clamp(0.0, size.width), + startPoint.dy.clamp(0.0, size.height), + ); + context.pushLayer( + LeaderLayer(link: _startHandleLayerLink, offset: startPoint), + super.paint, + Offset.zero, + ); + if (endpoints.length == 2) { + var endPoint = endpoints[1].point; + endPoint = Offset( + endPoint.dx.clamp(0.0, size.width), + endPoint.dy.clamp(0.0, size.height), + ); + context.pushLayer( + LeaderLayer(link: _endHandleLayerLink, offset: endPoint), + super.paint, + Offset.zero, + ); + } + } + + @override + double preferredLineHeight(TextPosition position) { + final child = childAtPosition(position); + return child.preferredLineHeight( + TextPosition(offset: position.offset - child.getContainer().offset)); + } + + @override + TextPosition getPositionForOffset(Offset offset) { + final local = globalToLocal(offset); + final child = childAtOffset(local)!; + + final parentData = child.parentData as BoxParentData; + final localOffset = local - parentData.offset; + final localPosition = child.getPositionForOffset(localOffset); + return TextPosition( + offset: localPosition.offset + child.getContainer().offset, + affinity: localPosition.affinity, + ); + } + + /// Returns the y-offset of the editor at which [selection] is visible. + /// + /// The offset is the distance from the top of the editor and is the minimum + /// from the current scroll position until [selection] becomes visible. + /// Returns null if [selection] is already visible. + double? getOffsetToRevealCursor( + double viewportHeight, double scrollOffset, double offsetInViewport) { + final endpoints = getEndpointsForSelection(selection); + final endpoint = endpoints.first; + final child = childAtPosition(selection.extent); + const kMargin = 8.0; + + final caretTop = endpoint.point.dy - + child.preferredLineHeight(TextPosition( + offset: selection.extentOffset - child.getContainer().offset)) - + kMargin + + offsetInViewport + + scrollBottomInset; + final caretBottom = + endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset; + double? dy; + if (caretTop < scrollOffset) { + dy = caretTop; + } else if (caretBottom > scrollOffset + viewportHeight) { + dy = caretBottom - viewportHeight; + } + if (dy == null) { + return null; + } + return math.max(dy, 0); + } +} + +class EditableContainerParentData + extends ContainerBoxParentData {} + +class RenderEditableContainerBox extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + RenderEditableContainerBox( + List? children, + this._container, + this.textDirection, + this.scrollBottomInset, + this._padding, + ) : assert(_padding.isNonNegative) { + addAll(children); + } + + container_node.Container _container; + TextDirection textDirection; + EdgeInsetsGeometry _padding; + double scrollBottomInset; + EdgeInsets? _resolvedPadding; + + container_node.Container getContainer() { + return _container; + } + + void setContainer(container_node.Container c) { + if (_container == c) { + return; + } + _container = c; + markNeedsLayout(); + } + + EdgeInsetsGeometry getPadding() => _padding; + + void setPadding(EdgeInsetsGeometry value) { + assert(value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedsPaddingResolution(); + } + + EdgeInsets? get resolvedPadding => _resolvedPadding; + + void _resolvePadding() { + if (_resolvedPadding != null) { + return; + } + _resolvedPadding = _padding.resolve(textDirection); + _resolvedPadding = _resolvedPadding!.copyWith(left: _resolvedPadding!.left); + + assert(_resolvedPadding!.isNonNegative); + } + + RenderEditableBox childAtPosition(TextPosition position) { + assert(firstChild != null); + + final targetNode = _container.queryChild(position.offset, false).node; + + var targetChild = firstChild; + while (targetChild != null) { + if (targetChild.getContainer() == targetNode) { + break; + } + targetChild = childAfter(targetChild); + } + if (targetChild == null) { + throw 'targetChild should not be null'; + } + return targetChild; + } + + void _markNeedsPaddingResolution() { + _resolvedPadding = null; + markNeedsLayout(); + } + + RenderEditableBox? childAtOffset(Offset offset) { + assert(firstChild != null); + _resolvePadding(); + + if (offset.dy <= _resolvedPadding!.top) { + return firstChild; + } + if (offset.dy >= size.height - _resolvedPadding!.bottom) { + return lastChild; + } + + var child = firstChild; + final dx = -offset.dx; + var dy = _resolvedPadding!.top; + while (child != null) { + if (child.size.contains(offset.translate(dx, -dy))) { + return child; + } + dy += child.size.height; + child = childAfter(child); + } + throw 'No child'; + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is EditableContainerParentData) { + return; + } + + child.parentData = EditableContainerParentData(); + } + + @override + void performLayout() { + assert(constraints.hasBoundedWidth); + _resolvePadding(); + assert(_resolvedPadding != null); + + var mainAxisExtent = _resolvedPadding!.top; + var child = firstChild; + final innerConstraints = + BoxConstraints.tightFor(width: constraints.maxWidth) + .deflate(_resolvedPadding!); + while (child != null) { + child.layout(innerConstraints, parentUsesSize: true); + final childParentData = (child.parentData as EditableContainerParentData) + ..offset = Offset(_resolvedPadding!.left, mainAxisExtent); + mainAxisExtent += child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + mainAxisExtent += _resolvedPadding!.bottom; + size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); + + assert(size.isFinite); + } + + double _getIntrinsicCrossAxis(double Function(RenderBox child) childSize) { + var extent = 0.0; + var child = firstChild; + while (child != null) { + extent = math.max(extent, childSize(child)); + final childParentData = child.parentData as EditableContainerParentData; + child = childParentData.nextSibling; + } + return extent; + } + + double _getIntrinsicMainAxis(double Function(RenderBox child) childSize) { + var extent = 0.0; + var child = firstChild; + while (child != null) { + extent += childSize(child); + final childParentData = child.parentData as EditableContainerParentData; + child = childParentData.nextSibling; + } + return extent; + } + + @override + double computeMinIntrinsicWidth(double height) { + _resolvePadding(); + return _getIntrinsicCrossAxis((child) { + final childHeight = math.max( + 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); + return child.getMinIntrinsicWidth(childHeight) + + _resolvedPadding!.left + + _resolvedPadding!.right; + }); + } + + @override + double computeMaxIntrinsicWidth(double height) { + _resolvePadding(); + return _getIntrinsicCrossAxis((child) { + final childHeight = math.max( + 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); + return child.getMaxIntrinsicWidth(childHeight) + + _resolvedPadding!.left + + _resolvedPadding!.right; + }); + } + + @override + double computeMinIntrinsicHeight(double width) { + _resolvePadding(); + return _getIntrinsicMainAxis((child) { + final childWidth = math.max( + 0, width - _resolvedPadding!.left + _resolvedPadding!.right); + return child.getMinIntrinsicHeight(childWidth) + + _resolvedPadding!.top + + _resolvedPadding!.bottom; + }); + } + + @override + double computeMaxIntrinsicHeight(double width) { + _resolvePadding(); + return _getIntrinsicMainAxis((child) { + final childWidth = math.max( + 0, width - _resolvedPadding!.left + _resolvedPadding!.right); + return child.getMaxIntrinsicHeight(childWidth) + + _resolvedPadding!.top + + _resolvedPadding!.bottom; + }); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + _resolvePadding(); + return defaultComputeDistanceToFirstActualBaseline(baseline)! + + _resolvedPadding!.top; + } +} diff --git a/lib/src/widgets/image.dart b/lib/src/widgets/image.dart new file mode 100644 index 00000000..b9df48ce --- /dev/null +++ b/lib/src/widgets/image.dart @@ -0,0 +1,31 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:photo_view/photo_view.dart'; + +class ImageTapWrapper extends StatelessWidget { + const ImageTapWrapper({ + this.imageProvider, + }); + + final ImageProvider? imageProvider; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + constraints: BoxConstraints.expand( + height: MediaQuery.of(context).size.height, + ), + child: GestureDetector( + onTapDown: (_) { + Navigator.pop(context); + }, + child: PhotoView( + imageProvider: imageProvider, + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/keyboard_listener.dart b/lib/src/widgets/keyboard_listener.dart new file mode 100644 index 00000000..17c47aad --- /dev/null +++ b/lib/src/widgets/keyboard_listener.dart @@ -0,0 +1,105 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +enum InputShortcut { CUT, COPY, PASTE, 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 { + KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete); + + 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.PASTE, + LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, + }; + + bool handleRawKeyEvent(RawKeyEvent event) { + if (kIsWeb) { + // On web platform, we should ignore the key because it's processed already. + return false; + } + + if (event is! RawKeyDownEvent) { + return false; + } + + final keysPressed = + LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); + final key = event.logicalKey; + final 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, + isMacOS ? event.isAltPressed : event.isControlPressed, + isMacOS ? event.isMetaPressed : event.isAltPressed, + 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/src/widgets/proxy.dart b/lib/src/widgets/proxy.dart new file mode 100644 index 00000000..8a04c4e1 --- /dev/null +++ b/lib/src/widgets/proxy.dart @@ -0,0 +1,298 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'box.dart'; + +class BaselineProxy extends SingleChildRenderObjectWidget { + const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding}) + : super(key: key, child: child); + + final TextStyle? textStyle; + final EdgeInsets? padding; + + @override + RenderBaselineProxy createRenderObject(BuildContext context) { + return RenderBaselineProxy( + null, + textStyle!, + padding, + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderBaselineProxy renderObject) { + renderObject + ..textStyle = textStyle! + ..padding = padding!; + } +} + +class RenderBaselineProxy extends RenderProxyBox { + RenderBaselineProxy( + RenderParagraph? child, + TextStyle textStyle, + EdgeInsets? padding, + ) : _prototypePainter = TextPainter( + text: TextSpan(text: ' ', style: textStyle), + textDirection: TextDirection.ltr, + strutStyle: + StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)), + super(child); + + final TextPainter _prototypePainter; + + set textStyle(TextStyle value) { + if (_prototypePainter.text!.style == value) { + return; + } + _prototypePainter.text = TextSpan(text: ' ', style: value); + markNeedsLayout(); + } + + EdgeInsets? _padding; + + set padding(EdgeInsets value) { + if (_padding == value) { + return; + } + _padding = value; + markNeedsLayout(); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) => + _prototypePainter.computeDistanceToActualBaseline(baseline); + // SEE What happens + _padding?.top; + + @override + void performLayout() { + super.performLayout(); + _prototypePainter.layout(); + } +} + +class EmbedProxy extends SingleChildRenderObjectWidget { + const EmbedProxy(Widget child) : super(child: child); + + @override + RenderEmbedProxy createRenderObject(BuildContext context) => + RenderEmbedProxy(null); +} + +class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { + RenderEmbedProxy(RenderBox? child) : super(child); + + @override + List getBoxesForSelection(TextSelection selection) { + if (!selection.isCollapsed) { + return [ + TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr) + ]; + } + + final left = selection.extentOffset == 0 ? 0.0 : size.width; + final right = selection.extentOffset == 0 ? 0.0 : size.width; + return [ + TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr) + ]; + } + + @override + double getFullHeightForCaret(TextPosition position) => size.height; + + @override + Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) { + assert(position.offset <= 1 && position.offset >= 0); + return position.offset == 0 ? Offset.zero : Offset(size.width, 0); + } + + @override + TextPosition getPositionForOffset(Offset offset) => + TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0); + + @override + TextRange getWordBoundary(TextPosition position) => + const TextRange(start: 0, end: 1); + + @override + double getPreferredLineHeight() { + return size.height; + } +} + +class RichTextProxy extends SingleChildRenderObjectWidget { + const RichTextProxy( + RichText child, + this.textStyle, + this.textAlign, + this.textDirection, + this.textScaleFactor, + this.locale, + this.strutStyle, + this.textWidthBasis, + this.textHeightBehavior, + ) : super(child: child); + + final TextStyle textStyle; + final TextAlign textAlign; + final TextDirection textDirection; + final double textScaleFactor; + final Locale locale; + final StrutStyle strutStyle; + final TextWidthBasis textWidthBasis; + final TextHeightBehavior? textHeightBehavior; + + @override + RenderParagraphProxy createRenderObject(BuildContext context) { + return RenderParagraphProxy( + null, + textStyle, + textAlign, + textDirection, + textScaleFactor, + strutStyle, + locale, + textWidthBasis, + textHeightBehavior); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderParagraphProxy renderObject) { + renderObject + ..textStyle = textStyle + ..textAlign = textAlign + ..textDirection = textDirection + ..textScaleFactor = textScaleFactor + ..locale = locale + ..strutStyle = strutStyle + ..textWidthBasis = textWidthBasis + ..textHeightBehavior = textHeightBehavior; + } +} + +class RenderParagraphProxy extends RenderProxyBox + implements RenderContentProxyBox { + RenderParagraphProxy( + RenderParagraph? child, + TextStyle textStyle, + TextAlign textAlign, + TextDirection textDirection, + double textScaleFactor, + StrutStyle strutStyle, + Locale locale, + TextWidthBasis textWidthBasis, + TextHeightBehavior? textHeightBehavior, + ) : _prototypePainter = TextPainter( + text: TextSpan(text: ' ', style: textStyle), + textAlign: textAlign, + textDirection: textDirection, + textScaleFactor: textScaleFactor, + strutStyle: strutStyle, + locale: locale, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior), + super(child); + + final TextPainter _prototypePainter; + + set textStyle(TextStyle value) { + if (_prototypePainter.text!.style == value) { + return; + } + _prototypePainter.text = TextSpan(text: ' ', style: value); + markNeedsLayout(); + } + + set textAlign(TextAlign value) { + if (_prototypePainter.textAlign == value) { + return; + } + _prototypePainter.textAlign = value; + markNeedsLayout(); + } + + set textDirection(TextDirection value) { + if (_prototypePainter.textDirection == value) { + return; + } + _prototypePainter.textDirection = value; + markNeedsLayout(); + } + + set textScaleFactor(double value) { + if (_prototypePainter.textScaleFactor == value) { + return; + } + _prototypePainter.textScaleFactor = value; + markNeedsLayout(); + } + + set strutStyle(StrutStyle value) { + if (_prototypePainter.strutStyle == value) { + return; + } + _prototypePainter.strutStyle = value; + markNeedsLayout(); + } + + set locale(Locale value) { + if (_prototypePainter.locale == value) { + return; + } + _prototypePainter.locale = value; + markNeedsLayout(); + } + + set textWidthBasis(TextWidthBasis value) { + if (_prototypePainter.textWidthBasis == value) { + return; + } + _prototypePainter.textWidthBasis = value; + markNeedsLayout(); + } + + set textHeightBehavior(TextHeightBehavior? value) { + if (_prototypePainter.textHeightBehavior == value) { + return; + } + _prototypePainter.textHeightBehavior = value; + markNeedsLayout(); + } + + @override + RenderParagraph? get child => super.child as RenderParagraph?; + + @override + double getPreferredLineHeight() { + return _prototypePainter.preferredLineHeight; + } + + @override + Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) => + child!.getOffsetForCaret(position, caretPrototype!); + + @override + TextPosition getPositionForOffset(Offset offset) => + child!.getPositionForOffset(offset); + + @override + double? getFullHeightForCaret(TextPosition position) => + child!.getFullHeightForCaret(position); + + @override + TextRange getWordBoundary(TextPosition position) => + child!.getWordBoundary(position); + + @override + List getBoxesForSelection(TextSelection selection) => + child!.getBoxesForSelection(selection); + + @override + void performLayout() { + super.performLayout(); + _prototypePainter.layout( + minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + } +} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart new file mode 100644 index 00000000..cd4f379c --- /dev/null +++ b/lib/src/widgets/raw_editor.dart @@ -0,0 +1,736 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.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/line.dart'; +import 'controller.dart'; +import 'cursor.dart'; +import 'default_styles.dart'; +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'; + +class RawEditor extends StatefulWidget { + const RawEditor( + Key key, + this.controller, + this.focusNode, + this.scrollController, + this.scrollable, + this.scrollBottomInset, + this.padding, + this.readOnly, + this.placeholder, + this.onLaunchUrl, + this.toolbarOptions, + this.showSelectionHandles, + bool? showCursor, + this.cursorStyle, + this.textCapitalization, + this.maxHeight, + this.minHeight, + this.customStyles, + this.expands, + this.autoFocus, + this.selectionColor, + this.selectionCtrls, + this.keyboardAppearance, + this.enableInteractiveSelection, + this.scrollPhysics, + this.embedBuilder, + ) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), + assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), + assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, + 'maxHeight cannot be null'), + showCursor = showCursor ?? true, + super(key: key); + + final QuillController controller; + final FocusNode focusNode; + final ScrollController scrollController; + final bool scrollable; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + final bool readOnly; + final String? placeholder; + final ValueChanged? onLaunchUrl; + final ToolbarOptions toolbarOptions; + final bool showSelectionHandles; + final bool showCursor; + final CursorStyle cursorStyle; + final TextCapitalization textCapitalization; + final double? maxHeight; + final double? minHeight; + final DefaultStyles? customStyles; + final bool expands; + final bool autoFocus; + final Color selectionColor; + final TextSelectionControls selectionCtrls; + final Brightness keyboardAppearance; + final bool enableInteractiveSelection; + final ScrollPhysics? scrollPhysics; + final EmbedBuilder embedBuilder; + + @override + State createState() => RawEditorState(); +} + +class RawEditorState extends EditorState + with + AutomaticKeepAliveClientMixin, + WidgetsBindingObserver, + TickerProviderStateMixin, + RawEditorStateKeyboardMixin, + RawEditorStateTextInputClientMixin, + RawEditorStateSelectionDelegateMixin { + final GlobalKey _editorKey = GlobalKey(); + + // Keyboard + late KeyboardListener _keyboardListener; + KeyboardVisibilityController? _keyboardVisibilityController; + StreamSubscription? _keyboardVisibilitySubscription; + bool _keyboardVisible = false; + + // Selection overlay + @override + EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; + EditorTextSelectionOverlay? _selectionOverlay; + + ScrollController? _scrollController; + + late CursorCont _cursorCont; + + // Focus + bool _didAutoFocus = false; + FocusAttachment? _focusAttachment; + bool get _hasFocus => widget.focusNode.hasFocus; + + DefaultStyles? _styles; + + final ClipboardStatusNotifier? _clipboardStatus = + kIsWeb ? null : ClipboardStatusNotifier(); + final LayerLink _toolbarLayerLink = LayerLink(); + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + + TextDirection get _textDirection => Directionality.of(context); + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + _focusAttachment!.reparent(); + super.build(context); + + var _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: _Editor( + key: _editorKey, + document: _doc, + selection: widget.controller.selection, + hasFocus: _hasFocus, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + children: _buildChildren(_doc, context), + ), + ), + ); + + if (widget.scrollable) { + final baselinePadding = + EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1); + child = BaselineProxy( + textStyle: _styles!.paragraph!.style, + padding: baselinePadding, + child: SingleChildScrollView( + controller: _scrollController, + physics: widget.scrollPhysics, + child: child, + ), + ); + } + + final constraints = widget.expands + ? const BoxConstraints.expand() + : BoxConstraints( + minHeight: widget.minHeight ?? 0.0, + maxHeight: widget.maxHeight ?? double.infinity); + + return QuillStyles( + data: _styles!, + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: Container( + constraints: constraints, + child: child, + ), + ), + ); + } + + void _handleSelectionChanged( + TextSelection selection, SelectionChangedCause cause) { + widget.controller.updateSelection(selection, ChangeSource.LOCAL); + + _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); + + if (!_keyboardVisible) { + requestKeyboard(); + } + } + + /// 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 = {}; + 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, + 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.'); + } + } + return result; + } + + EditableTextLine _getEditableTextLineFromNode( + Line node, BuildContext context) { + final textLine = TextLine( + line: node, + textDirection: _textDirection, + embedBuilder: widget.embedBuilder, + styles: _styles!, + ); + final editableTextLine = EditableTextLine( + node, + null, + textLine, + 0, + _getVerticalSpacingForLine(node, _styles), + _textDirection, + widget.controller.selection, + widget.selectionColor, + widget.enableInteractiveSelection, + _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; + } + + @override + void initState() { + super.initState(); + + _clipboardStatus?.addListener(_onChangedClipboardStatus); + + widget.controller.addListener(() { + _didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange); + }); + + _scrollController = widget.scrollController; + _scrollController!.addListener(_updateSelectionOverlayForScroll); + + _cursorCont = CursorCont( + show: ValueNotifier(widget.showCursor), + style: widget.cursorStyle, + tickerProvider: this, + ); + + _keyboardListener = KeyboardListener( + handleCursorMovement, + handleShortcut, + handleDelete, + ); + + if (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.fuchsia) { + _keyboardVisible = true; + } else { + _keyboardVisibilityController = KeyboardVisibilityController(); + _keyboardVisible = _keyboardVisibilityController!.isVisible; + _keyboardVisibilitySubscription = + _keyboardVisibilityController?.onChange.listen((visible) { + _keyboardVisible = visible; + if (visible) { + _onChangeTextEditingValue(); + } + }); + } + + _focusAttachment = widget.focusNode.attach(context, + onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); + widget.focusNode.addListener(_handleFocusChanged); + } + + @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!); + } + + if (!_didAutoFocus && widget.autoFocus) { + FocusScope.of(context).autofocus(widget.focusNode); + _didAutoFocus = true; + } + } + + @override + void didUpdateWidget(RawEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + _cursorCont.show.value = widget.showCursor; + _cursorCont.style = widget.cursorStyle; + + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_didChangeTextEditingValue); + widget.controller.addListener(_didChangeTextEditingValue); + updateRemoteValueIfNeeded(); + } + + if (widget.scrollController != _scrollController) { + _scrollController!.removeListener(_updateSelectionOverlayForScroll); + _scrollController = widget.scrollController; + _scrollController!.addListener(_updateSelectionOverlayForScroll); + } + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + _focusAttachment?.detach(); + _focusAttachment = widget.focusNode.attach(context, + onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); + widget.focusNode.addListener(_handleFocusChanged); + updateKeepAlive(); + } + + if (widget.controller.selection != oldWidget.controller.selection) { + _selectionOverlay?.update(textEditingValue); + } + + _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); + if (!shouldCreateInputConnection) { + closeConnectionIfNeeded(); + } else { + if (oldWidget.readOnly && _hasFocus) { + openConnectionIfNeeded(); + } + } + } + + bool _shouldShowSelectionHandles() { + return widget.showSelectionHandles && + !widget.controller.selection.isCollapsed; + } + + @override + void dispose() { + closeConnectionIfNeeded(); + _keyboardVisibilitySubscription?.cancel(); + assert(!hasConnection); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + widget.controller.removeListener(_didChangeTextEditingValue); + widget.focusNode.removeListener(_handleFocusChanged); + _focusAttachment!.detach(); + _cursorCont.dispose(); + _clipboardStatus?.removeListener(_onChangedClipboardStatus); + _clipboardStatus?.dispose(); + super.dispose(); + } + + void _updateSelectionOverlayForScroll() { + _selectionOverlay?.markNeedsBuild(); + } + + void _didChangeTextEditingValue([bool ignoreFocus = false]) { + if (kIsWeb) { + _onChangeTextEditingValue(ignoreFocus); + if (!ignoreFocus) { + requestKeyboard(); + } + return; + } + + if (ignoreFocus || _keyboardVisible) { + _onChangeTextEditingValue(ignoreFocus); + } else { + requestKeyboard(); + if (mounted) { + setState(() { + // Use widget.controller.value in build() + // Trigger build and updateChildren + }); + } + } + } + + void _onChangeTextEditingValue([bool ignoreCaret = false]) { + updateRemoteValueIfNeeded(); + if (ignoreCaret) { + return; + } + _showCaretOnScreen(); + _cursorCont.startOrStopCursorTimerIfNeeded( + _hasFocus, widget.controller.selection); + if (hasConnection) { + _cursorCont + ..stopCursorTimer(resetCharTicks: false) + ..startCursorTimer(); + } + + SchedulerBinding.instance!.addPostFrameCallback( + (_) => _updateOrDisposeSelectionOverlayIfNeeded()); + if (mounted) { + setState(() { + // Use widget.controller.value in build() + // Trigger build and updateChildren + }); + } + } + + void _updateOrDisposeSelectionOverlayIfNeeded() { + if (_selectionOverlay != null) { + if (_hasFocus) { + _selectionOverlay!.update(textEditingValue); + } else { + _selectionOverlay!.dispose(); + _selectionOverlay = null; + } + } else if (_hasFocus) { + _selectionOverlay?.hide(); + _selectionOverlay = null; + + _selectionOverlay = EditorTextSelectionOverlay( + textEditingValue, + false, + context, + widget, + _toolbarLayerLink, + _startHandleLayerLink, + _endHandleLayerLink, + getRenderEditor(), + widget.selectionCtrls, + this, + DragStartBehavior.start, + null, + _clipboardStatus!, + ); + _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); + _selectionOverlay!.showHandles(); + } + } + + void _handleFocusChanged() { + openOrCloseConnection(); + _cursorCont.startOrStopCursorTimerIfNeeded( + _hasFocus, widget.controller.selection); + _updateOrDisposeSelectionOverlayIfNeeded(); + if (_hasFocus) { + WidgetsBinding.instance!.addObserver(this); + _showCaretOnScreen(); + } else { + WidgetsBinding.instance!.removeObserver(this); + } + updateKeepAlive(); + } + + void _onChangedClipboardStatus() { + if (!mounted) return; + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + // Trigger build and updateChildren + }); + } + + bool _showCaretOnScreenScheduled = false; + + void _showCaretOnScreen() { + if (!widget.showCursor || _showCaretOnScreenScheduled) { + return; + } + + _showCaretOnScreenScheduled = true; + SchedulerBinding.instance!.addPostFrameCallback((_) { + 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 offset = getRenderEditor()!.getOffsetToRevealCursor( + _scrollController!.position.viewportDimension, + _scrollController!.offset, + offsetInViewport, + ); + + if (offset != null) { + _scrollController!.animateTo( + offset, + duration: const Duration(milliseconds: 100), + curve: Curves.fastOutSlowIn, + ); + } + } + }); + } + + @override + RenderEditor? getRenderEditor() { + return _editorKey.currentContext!.findRenderObject() as RenderEditor?; + } + + @override + TextEditingValue getTextEditingValue() { + return widget.controller.plainTextEditingValue; + } + + @override + void requestKeyboard() { + if (_hasFocus) { + openConnectionIfNeeded(); + } else { + widget.focusNode.requestFocus(); + } + } + + @override + void setTextEditingValue(TextEditingValue value) { + if (value.text == textEditingValue.text) { + widget.controller.updateSelection(value.selection, ChangeSource.LOCAL); + } else { + __setEditingValue(value); + } + } + + Future __setEditingValue(TextEditingValue value) async { + if (await __isItCut(value)) { + widget.controller.replaceText( + textEditingValue.selection.start, + textEditingValue.text.length - value.text.length, + '', + value.selection, + ); + } else { + final value = textEditingValue; + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null) { + final length = + textEditingValue.selection.end - textEditingValue.selection.start; + widget.controller.replaceText( + value.selection.start, + length, + data.text, + value.selection, + ); + // move cursor to the end of pasted text selection + widget.controller.updateSelection( + TextSelection.collapsed( + offset: value.selection.start + data.text!.length), + ChangeSource.LOCAL); + } + } + } + + Future __isItCut(TextEditingValue value) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data == null) { + return false; + } + return textEditingValue.text.length - value.text.length == + data.text!.length; + } + + @override + bool showToolbar() { + // Web is using native dom elements to enable clipboard functionality of the + // toolbar: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this + // we should not show a Flutter toolbar for the editable text elements. + if (kIsWeb) { + return false; + } + if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) { + return false; + } + + _selectionOverlay!.update(textEditingValue); + _selectionOverlay!.showToolbar(); + return true; + } + + @override + bool get wantKeepAlive => widget.focusNode.hasFocus; + + @override + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause cause) { + // TODO: implement userUpdateTextEditingValue + } +} + +class _Editor extends MultiChildRenderObjectWidget { + _Editor({ + required Key key, + required List children, + required this.document, + required this.textDirection, + required this.hasFocus, + required this.selection, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.onSelectionChanged, + required this.scrollBottomInset, + this.padding = EdgeInsets.zero, + }) : super(key: key, children: children); + + final Document document; + final TextDirection textDirection; + final bool hasFocus; + final TextSelection selection; + 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, + selection, + 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 + ..setHasFocus(hasFocus) + ..setSelection(selection) + ..setStartHandleLayerLink(startHandleLayerLink) + ..setEndHandleLayerLink(endHandleLayerLink) + ..onSelectionChanged = onSelectionChanged + ..setScrollBottomInset(scrollBottomInset) + ..setPadding(padding); + } +} diff --git a/lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart new file mode 100644 index 00000000..0eb7f955 --- /dev/null +++ b/lib/src/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/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart new file mode 100644 index 00000000..cda991cc --- /dev/null +++ b/lib/src/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/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart new file mode 100644 index 00000000..527df582 --- /dev/null +++ b/lib/src/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/src/widgets/responsive_widget.dart b/lib/src/widgets/responsive_widget.dart new file mode 100644 index 00000000..3829565c --- /dev/null +++ b/lib/src/widgets/responsive_widget.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class ResponsiveWidget extends StatelessWidget { + const ResponsiveWidget({ + required this.largeScreen, + this.mediumScreen, + this.smallScreen, + Key? key, + }) : super(key: key); + + final Widget largeScreen; + final Widget? mediumScreen; + final Widget? smallScreen; + + static bool isSmallScreen(BuildContext context) { + return MediaQuery.of(context).size.width < 800; + } + + static bool isLargeScreen(BuildContext context) { + return MediaQuery.of(context).size.width > 1200; + } + + static bool isMediumScreen(BuildContext context) { + return MediaQuery.of(context).size.width >= 800 && + MediaQuery.of(context).size.width <= 1200; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 1200) { + return largeScreen; + } else if (constraints.maxWidth <= 1200 && + constraints.maxWidth >= 800) { + return mediumScreen ?? largeScreen; + } else { + return smallScreen ?? largeScreen; + } + }, + ); + } +} diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart new file mode 100644 index 00000000..ee1a7732 --- /dev/null +++ b/lib/src/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/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart new file mode 100644 index 00000000..f533a160 --- /dev/null +++ b/lib/src/widgets/text_block.dart @@ -0,0 +1,737 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:tuple/tuple.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/nodes/block.dart'; +import '../models/documents/nodes/line.dart'; +import 'box.dart'; +import 'cursor.dart'; +import 'default_styles.dart'; +import 'delegate.dart'; +import 'editor.dart'; +import 'text_line.dart'; +import 'text_selection.dart'; + +const List arabianRomanNumbers = [ + 1000, + 900, + 500, + 400, + 100, + 90, + 50, + 40, + 10, + 9, + 5, + 4, + 1 +]; + +const List romanNumbers = [ + 'M', + 'CM', + 'D', + 'CD', + 'C', + 'XC', + 'L', + 'XL', + 'X', + 'IX', + 'V', + 'IV', + 'I' +]; + +class EditableTextBlock extends StatelessWidget { + const EditableTextBlock( + this.block, + this.textDirection, + this.scrollBottomInset, + this.verticalSpacing, + this.textSelection, + this.color, + this.styles, + this.enableInteractiveSelection, + this.hasFocus, + this.contentPadding, + this.embedBuilder, + this.cursorCont, + this.indentLevelCounts, + this.onCheckboxTap, + ); + + final Block block; + final TextDirection textDirection; + final double scrollBottomInset; + final Tuple2 verticalSpacing; + final TextSelection textSelection; + final Color color; + final DefaultStyles? styles; + final bool enableInteractiveSelection; + final bool hasFocus; + final EdgeInsets? contentPadding; + final EmbedBuilder embedBuilder; + final CursorCont cursorCont; + final Map indentLevelCounts; + final Function(int, bool) onCheckboxTap; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + final defaultStyles = QuillStyles.getStyles(context, false); + return _EditableBlock( + block, + textDirection, + verticalSpacing as Tuple2, + scrollBottomInset, + _getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(), + contentPadding, + _buildChildren(context, indentLevelCounts)); + } + + BoxDecoration? _getDecorationForBlock( + Block node, DefaultStyles? defaultStyles) { + final attrs = block.style.attributes; + if (attrs.containsKey(Attribute.blockQuote.key)) { + return defaultStyles!.quote!.decoration; + } + if (attrs.containsKey(Attribute.codeBlock.key)) { + return defaultStyles!.code!.decoration; + } + return null; + } + + List _buildChildren( + BuildContext context, Map indentLevelCounts) { + final defaultStyles = QuillStyles.getStyles(context, false); + final count = block.children.length; + final children = []; + var index = 0; + for (final line in Iterable.castFrom(block.children)) { + index++; + final editableTextLine = EditableTextLine( + line, + _buildLeading(context, line, index, indentLevelCounts, count), + TextLine( + line: line, + textDirection: textDirection, + embedBuilder: embedBuilder, + styles: styles!, + ), + _getIndentWidth(), + _getSpacingForLine(line, index, count, defaultStyles), + textDirection, + textSelection, + color, + enableInteractiveSelection, + hasFocus, + MediaQuery.of(context).devicePixelRatio, + cursorCont); + children.add(editableTextLine); + } + return children.toList(growable: false); + } + + Widget? _buildLeading(BuildContext context, Line line, int index, + Map indentLevelCounts, int count) { + final defaultStyles = QuillStyles.getStyles(context, false); + final attrs = line.style.attributes; + if (attrs[Attribute.list.key] == Attribute.ol) { + return _NumberPoint( + index: index, + indentLevelCounts: indentLevelCounts, + count: count, + style: defaultStyles!.leading!.style, + attrs: attrs, + width: 32, + padding: 8, + ); + } + + if (attrs[Attribute.list.key] == Attribute.ul) { + return _BulletPoint( + style: + defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), + width: 32, + ); + } + + if (attrs[Attribute.list.key] == Attribute.checked) { + return _Checkbox( + 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( + key: UniqueKey(), + style: defaultStyles!.leading!.style, + width: 32, + offset: block.offset + line.offset, + onTap: onCheckboxTap, + ); + } + + if (attrs.containsKey(Attribute.codeBlock.key)) { + return _NumberPoint( + index: index, + indentLevelCounts: indentLevelCounts, + count: count, + style: defaultStyles!.code!.style + .copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)), + width: 32, + attrs: attrs, + padding: 16, + withDot: false, + ); + } + return null; + } + + double _getIndentWidth() { + final attrs = block.style.attributes; + + final indent = attrs[Attribute.indent.key]; + var extraIndent = 0.0; + if (indent != null && indent.value != null) { + extraIndent = 16.0 * indent.value; + } + + if (attrs.containsKey(Attribute.blockQuote.key)) { + return 16.0 + extraIndent; + } + + return 32.0 + extraIndent; + } + + Tuple2 _getSpacingForLine( + Line node, int index, int count, DefaultStyles? defaultStyles) { + var top = 0.0, bottom = 0.0; + + final attrs = block.style.attributes; + if (attrs.containsKey(Attribute.header.key)) { + final level = attrs[Attribute.header.key]!.value; + switch (level) { + case 1: + top = defaultStyles!.h1!.verticalSpacing.item1; + bottom = defaultStyles.h1!.verticalSpacing.item2; + break; + case 2: + top = defaultStyles!.h2!.verticalSpacing.item1; + bottom = defaultStyles.h2!.verticalSpacing.item2; + break; + case 3: + top = defaultStyles!.h3!.verticalSpacing.item1; + bottom = defaultStyles.h3!.verticalSpacing.item2; + break; + default: + throw 'Invalid level $level'; + } + } else { + late Tuple2 lineSpacing; + if (attrs.containsKey(Attribute.blockQuote.key)) { + lineSpacing = defaultStyles!.quote!.lineSpacing; + } else if (attrs.containsKey(Attribute.indent.key)) { + lineSpacing = defaultStyles!.indent!.lineSpacing; + } else if (attrs.containsKey(Attribute.list.key)) { + lineSpacing = defaultStyles!.lists!.lineSpacing; + } else if (attrs.containsKey(Attribute.codeBlock.key)) { + lineSpacing = defaultStyles!.code!.lineSpacing; + } else if (attrs.containsKey(Attribute.align.key)) { + lineSpacing = defaultStyles!.align!.lineSpacing; + } + top = lineSpacing.item1; + bottom = lineSpacing.item2; + } + + if (index == 1) { + top = 0.0; + } + + if (index == count) { + bottom = 0.0; + } + + return Tuple2(top, bottom); + } +} + +class RenderEditableTextBlock extends RenderEditableContainerBox + implements RenderEditableBox { + RenderEditableTextBlock({ + required Block block, + required TextDirection textDirection, + required EdgeInsetsGeometry padding, + required double scrollBottomInset, + required Decoration decoration, + List? children, + ImageConfiguration configuration = ImageConfiguration.empty, + EdgeInsets contentPadding = EdgeInsets.zero, + }) : _decoration = decoration, + _configuration = configuration, + _savedPadding = padding, + _contentPadding = contentPadding, + super( + children, + block, + textDirection, + scrollBottomInset, + padding.add(contentPadding), + ); + + EdgeInsetsGeometry _savedPadding; + EdgeInsets _contentPadding; + + set contentPadding(EdgeInsets value) { + if (_contentPadding == value) return; + _contentPadding = value; + super.setPadding(_savedPadding.add(_contentPadding)); + } + + @override + void setPadding(EdgeInsetsGeometry value) { + super.setPadding(value.add(_contentPadding)); + _savedPadding = value; + } + + BoxPainter? _painter; + + Decoration get decoration => _decoration; + Decoration _decoration; + + set decoration(Decoration value) { + if (value == _decoration) return; + _painter?.dispose(); + _painter = null; + _decoration = value; + markNeedsPaint(); + } + + ImageConfiguration get configuration => _configuration; + ImageConfiguration _configuration; + + set configuration(ImageConfiguration value) { + if (value == _configuration) return; + _configuration = value; + markNeedsPaint(); + } + + @override + TextRange getLineBoundary(TextPosition position) { + final child = childAtPosition(position); + final rangeInChild = child.getLineBoundary(TextPosition( + offset: position.offset - child.getContainer().offset, + affinity: position.affinity, + )); + return TextRange( + start: rangeInChild.start + child.getContainer().offset, + end: rangeInChild.end + child.getContainer().offset, + ); + } + + @override + Offset getOffsetForCaret(TextPosition position) { + final child = childAtPosition(position); + return child.getOffsetForCaret(TextPosition( + offset: position.offset - child.getContainer().offset, + affinity: position.affinity, + )) + + (child.parentData as BoxParentData).offset; + } + + @override + TextPosition getPositionForOffset(Offset offset) { + final child = childAtOffset(offset)!; + final parentData = child.parentData as BoxParentData; + final localPosition = + child.getPositionForOffset(offset - parentData.offset); + return TextPosition( + offset: localPosition.offset + child.getContainer().offset, + affinity: localPosition.affinity, + ); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final child = childAtPosition(position); + final nodeOffset = child.getContainer().offset; + final childWord = child + .getWordBoundary(TextPosition(offset: position.offset - nodeOffset)); + return TextRange( + start: childWord.start + nodeOffset, + end: childWord.end + nodeOffset, + ); + } + + @override + TextPosition? getPositionAbove(TextPosition position) { + assert(position.offset < getContainer().length); + + final child = childAtPosition(position); + final childLocalPosition = + TextPosition(offset: position.offset - child.getContainer().offset); + final result = child.getPositionAbove(childLocalPosition); + if (result != null) { + return TextPosition(offset: result.offset + child.getContainer().offset); + } + + final sibling = childBefore(child); + if (sibling == null) { + return null; + } + + final caretOffset = child.getOffsetForCaret(childLocalPosition); + final testPosition = + TextPosition(offset: sibling.getContainer().length - 1); + final testOffset = sibling.getOffsetForCaret(testPosition); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + return TextPosition( + offset: sibling.getContainer().offset + + sibling.getPositionForOffset(finalOffset).offset); + } + + @override + TextPosition? getPositionBelow(TextPosition position) { + assert(position.offset < getContainer().length); + + final child = childAtPosition(position); + final childLocalPosition = + TextPosition(offset: position.offset - child.getContainer().offset); + final result = child.getPositionBelow(childLocalPosition); + if (result != null) { + return TextPosition(offset: result.offset + child.getContainer().offset); + } + + final sibling = childAfter(child); + if (sibling == null) { + return null; + } + + final caretOffset = child.getOffsetForCaret(childLocalPosition); + final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0)); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + return TextPosition( + offset: sibling.getContainer().offset + + sibling.getPositionForOffset(finalOffset).offset); + } + + @override + double preferredLineHeight(TextPosition position) { + final child = childAtPosition(position); + return child.preferredLineHeight( + TextPosition(offset: position.offset - child.getContainer().offset)); + } + + @override + TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) { + if (selection.isCollapsed) { + return TextSelectionPoint( + Offset(0, preferredLineHeight(selection.extent)) + + getOffsetForCaret(selection.extent), + null); + } + + final baseNode = getContainer().queryChild(selection.start, false).node; + var baseChild = firstChild; + while (baseChild != null) { + if (baseChild.getContainer() == baseNode) { + break; + } + baseChild = childAfter(baseChild); + } + assert(baseChild != null); + + final basePoint = baseChild!.getBaseEndpointForSelection( + localSelection(baseChild.getContainer(), selection, true)); + return TextSelectionPoint( + basePoint.point + (baseChild.parentData as BoxParentData).offset, + basePoint.direction); + } + + @override + TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) { + if (selection.isCollapsed) { + return TextSelectionPoint( + Offset(0, preferredLineHeight(selection.extent)) + + getOffsetForCaret(selection.extent), + null); + } + + final extentNode = getContainer().queryChild(selection.end, false).node; + + var extentChild = firstChild; + while (extentChild != null) { + if (extentChild.getContainer() == extentNode) { + break; + } + extentChild = childAfter(extentChild); + } + assert(extentChild != null); + + final extentPoint = extentChild!.getExtentEndpointForSelection( + localSelection(extentChild.getContainer(), selection, true)); + return TextSelectionPoint( + extentPoint.point + (extentChild.parentData as BoxParentData).offset, + extentPoint.direction); + } + + @override + void detach() { + _painter?.dispose(); + _painter = null; + super.detach(); + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _paintDecoration(context, offset); + defaultPaint(context, offset); + } + + void _paintDecoration(PaintingContext context, Offset offset) { + _painter ??= _decoration.createBoxPainter(markNeedsPaint); + + final decorationPadding = resolvedPadding! - _contentPadding; + + final filledConfiguration = + configuration.copyWith(size: decorationPadding.deflateSize(size)); + final debugSaveCount = context.canvas.getSaveCount(); + + final decorationOffset = + offset.translate(decorationPadding.left, decorationPadding.top); + _painter!.paint(context.canvas, decorationOffset, filledConfiguration); + if (debugSaveCount != context.canvas.getSaveCount()) { + throw '${_decoration.runtimeType} painter had mismatching save and restore calls.'; + } + if (decoration.isComplex) { + context.setIsComplexHint(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } +} + +class _EditableBlock extends MultiChildRenderObjectWidget { + _EditableBlock( + this.block, + this.textDirection, + this.padding, + this.scrollBottomInset, + this.decoration, + this.contentPadding, + List children) + : super(children: children); + + final Block block; + final TextDirection textDirection; + final Tuple2 padding; + final double scrollBottomInset; + final Decoration decoration; + final EdgeInsets? contentPadding; + + EdgeInsets get _padding => + EdgeInsets.only(top: padding.item1, bottom: padding.item2); + + EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; + + @override + RenderEditableTextBlock createRenderObject(BuildContext context) { + return RenderEditableTextBlock( + block: block, + textDirection: textDirection, + padding: _padding, + scrollBottomInset: scrollBottomInset, + decoration: decoration, + contentPadding: _contentPadding, + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderEditableTextBlock renderObject) { + renderObject + ..setContainer(block) + ..textDirection = textDirection + ..scrollBottomInset = scrollBottomInset + ..setPadding(_padding) + ..decoration = decoration + ..contentPadding = _contentPadding; + } +} + +class _NumberPoint extends StatelessWidget { + const _NumberPoint({ + required this.index, + required this.indentLevelCounts, + required this.count, + required this.style, + required this.width, + required this.attrs, + this.withDot = true, + this.padding = 0.0, + Key? key, + }) : super(key: key); + + final int index; + final Map indentLevelCounts; + final int count; + final TextStyle style; + final double width; + final Map attrs; + final bool withDot; + final double padding; + + @override + Widget build(BuildContext context) { + var s = index.toString(); + int? level = 0; + if (!attrs.containsKey(Attribute.indent.key) && + !indentLevelCounts.containsKey(1)) { + indentLevelCounts.clear(); + return Container( + alignment: AlignmentDirectional.topEnd, + width: width, + padding: EdgeInsetsDirectional.only(end: padding), + child: Text(withDot ? '$s.' : s, style: style), + ); + } + if (attrs.containsKey(Attribute.indent.key)) { + level = attrs[Attribute.indent.key]!.value; + } else { + // first level but is back from previous indent level + // supposed to be "2." + indentLevelCounts[0] = 1; + } + if (indentLevelCounts.containsKey(level! + 1)) { + // last visited level is done, going up + indentLevelCounts.remove(level + 1); + } + final count = (indentLevelCounts[level] ?? 0) + 1; + indentLevelCounts[level] = count; + + s = count.toString(); + if (level % 3 == 1) { + // a. b. c. d. e. ... + s = _toExcelSheetColumnTitle(count); + } else if (level % 3 == 2) { + // i. ii. iii. ... + s = _intToRoman(count); + } + // level % 3 == 0 goes back to 1. 2. 3. + + return Container( + alignment: AlignmentDirectional.topEnd, + width: width, + padding: EdgeInsetsDirectional.only(end: padding), + child: Text(withDot ? '$s.' : s, style: style), + ); + } + + String _toExcelSheetColumnTitle(int n) { + final result = StringBuffer(); + while (n > 0) { + n--; + result.write(String.fromCharCode((n % 26).floor() + 97)); + n = (n / 26).floor(); + } + + return result.toString().split('').reversed.join(); + } + + String _intToRoman(int input) { + var num = input; + + if (num < 0) { + return ''; + } else if (num == 0) { + return 'nulla'; + } + + final builder = StringBuffer(); + for (var a = 0; a < arabianRomanNumbers.length; a++) { + final times = (num / arabianRomanNumbers[a]) + .truncate(); // equals 1 only when arabianRomanNumbers[a] = num + // executes n times where n is the number of times you have to add + // the current roman number value to reach current num. + builder.write(romanNumbers[a] * times); + num -= times * + arabianRomanNumbers[ + a]; // subtract previous roman number value from num + } + + return builder.toString().toLowerCase(); + } +} + +class _BulletPoint extends StatelessWidget { + const _BulletPoint({ + required this.style, + required this.width, + Key? key, + }) : super(key: key); + + final TextStyle style; + final double width; + + @override + Widget build(BuildContext context) { + return Container( + alignment: AlignmentDirectional.topEnd, + width: width, + padding: const EdgeInsetsDirectional.only(end: 13), + child: Text('•', style: style), + ); + } +} + +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 int? offset; + final Function(int, bool)? onTap; + + 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: width, + padding: const EdgeInsetsDirectional.only(end: 13), + child: GestureDetector( + onLongPress: () => _onCheckboxClicked(!isChecked), + child: Checkbox( + value: isChecked, + onChanged: _onCheckboxClicked, + ), + ), + ); + } +} diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart new file mode 100644 index 00000000..ef80a024 --- /dev/null +++ b/lib/src/widgets/text_line.dart @@ -0,0 +1,892 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:tuple/tuple.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/nodes/container.dart' as container; +import '../models/documents/nodes/leaf.dart' as leaf; +import '../models/documents/nodes/leaf.dart'; +import '../models/documents/nodes/line.dart'; +import '../models/documents/nodes/node.dart'; +import '../utils/color.dart'; +import 'box.dart'; +import 'cursor.dart'; +import 'default_styles.dart'; +import 'delegate.dart'; +import 'proxy.dart'; +import 'text_selection.dart'; + +class TextLine extends StatelessWidget { + const TextLine({ + required this.line, + required this.embedBuilder, + required this.styles, + this.textDirection, + Key? key, + }) : super(key: key); + + final Line line; + final TextDirection? textDirection; + final EmbedBuilder embedBuilder; + final DefaultStyles styles; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + if (line.hasEmbed) { + final embed = line.children.single as Embed; + return EmbedProxy(embedBuilder(context, embed)); + } + + final textSpan = _buildTextSpan(context); + final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); + final textAlign = _getTextAlign(); + final child = RichText( + text: textSpan, + textAlign: textAlign, + textDirection: textDirection, + strutStyle: strutStyle, + textScaleFactor: MediaQuery.textScaleFactorOf(context), + ); + return RichTextProxy( + child, + textSpan.style!, + textAlign, + textDirection!, + 1, + Localizations.localeOf(context), + strutStyle, + TextWidthBasis.parent, + null); + } + + TextAlign _getTextAlign() { + final alignment = line.style.attributes[Attribute.align.key]; + if (alignment == Attribute.leftAlignment) { + return TextAlign.left; + } else if (alignment == Attribute.centerAlignment) { + return TextAlign.center; + } else if (alignment == Attribute.rightAlignment) { + return TextAlign.right; + } else if (alignment == Attribute.justifyAlignment) { + return TextAlign.justify; + } + return TextAlign.start; + } + + TextSpan _buildTextSpan(BuildContext context) { + final defaultStyles = styles; + final children = line.children + .map((node) => _getTextSpanFromNode(defaultStyles, node)) + .toList(growable: false); + + var textStyle = const TextStyle(); + + if (line.style.containsKey(Attribute.placeholder.key)) { + textStyle = defaultStyles.placeHolder!.style; + return TextSpan(children: children, style: textStyle); + } + + final header = line.style.attributes[Attribute.header.key]; + final m = { + Attribute.h1: defaultStyles.h1!.style, + Attribute.h2: defaultStyles.h2!.style, + Attribute.h3: defaultStyles.h3!.style, + }; + + textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); + + final block = line.style.getBlockExceptHeader(); + TextStyle? toMerge; + if (block == Attribute.blockQuote) { + toMerge = defaultStyles.quote!.style; + } else if (block == Attribute.codeBlock) { + toMerge = defaultStyles.code!.style; + } else if (block != null) { + toMerge = defaultStyles.lists!.style; + } + + textStyle = textStyle.merge(toMerge); + + return TextSpan(children: children, style: textStyle); + } + + TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { + final textNode = node as leaf.Text; + final style = textNode.style; + var res = const TextStyle(); + final color = textNode.style.attributes[Attribute.color.key]; + + { + Attribute.bold.key: defaultStyles.bold, + Attribute.italic.key: defaultStyles.italic, + Attribute.link.key: defaultStyles.link, + Attribute.underline.key: defaultStyles.underline, + Attribute.strikeThrough.key: defaultStyles.strikeThrough, + }.forEach((k, s) { + if (style.values.any((v) => v.key == k)) { + if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) { + var textColor = defaultStyles.color; + if (color?.value is String) { + textColor = stringToColor(color?.value); + } + res = _merge(res.copyWith(decorationColor: textColor), + s!.copyWith(decorationColor: textColor)); + } else { + res = _merge(res, s!); + } + } + }); + + final font = textNode.style.attributes[Attribute.font.key]; + if (font != null && font.value != null) { + res = res.merge(TextStyle(fontFamily: font.value)); + } + + final size = textNode.style.attributes[Attribute.size.key]; + if (size != null && size.value != null) { + switch (size.value) { + case 'small': + res = res.merge(defaultStyles.sizeSmall); + break; + case 'large': + res = res.merge(defaultStyles.sizeLarge); + break; + case 'huge': + res = res.merge(defaultStyles.sizeHuge); + break; + default: + final fontSize = double.tryParse(size.value); + if (fontSize != null) { + res = res.merge(TextStyle(fontSize: fontSize)); + } else { + throw 'Invalid size ${size.value}'; + } + } + } + + if (color != null && color.value != null) { + var textColor = defaultStyles.color; + if (color.value is String) { + textColor = stringToColor(color.value); + } + if (textColor != null) { + res = res.merge(TextStyle(color: textColor)); + } + } + + final background = textNode.style.attributes[Attribute.background.key]; + if (background != null && background.value != null) { + final backgroundColor = stringToColor(background.value); + res = res.merge(TextStyle(backgroundColor: backgroundColor)); + } + + return TextSpan(text: textNode.value, style: res); + } + + TextStyle _merge(TextStyle a, TextStyle b) { + final decorations = []; + if (a.decoration != null) { + decorations.add(a.decoration); + } + if (b.decoration != null) { + decorations.add(b.decoration); + } + return a.merge(b).apply( + decoration: TextDecoration.combine( + List.castFrom(decorations))); + } +} + +class EditableTextLine extends RenderObjectWidget { + const EditableTextLine( + this.line, + this.leading, + this.body, + this.indentWidth, + this.verticalSpacing, + this.textDirection, + this.textSelection, + this.color, + this.enableInteractiveSelection, + this.hasFocus, + this.devicePixelRatio, + this.cursorCont, + ); + + final Line line; + final Widget? leading; + final Widget body; + final double indentWidth; + final Tuple2 verticalSpacing; + final TextDirection textDirection; + final TextSelection textSelection; + final Color color; + final bool enableInteractiveSelection; + final bool hasFocus; + final double devicePixelRatio; + final CursorCont cursorCont; + + @override + RenderObjectElement createElement() { + return _TextLineElement(this); + } + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderEditableTextLine( + line, + textDirection, + textSelection, + enableInteractiveSelection, + hasFocus, + devicePixelRatio, + _getPadding(), + color, + cursorCont); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderEditableTextLine renderObject) { + renderObject + ..setLine(line) + ..setPadding(_getPadding()) + ..setTextDirection(textDirection) + ..setTextSelection(textSelection) + ..setColor(color) + ..setEnableInteractiveSelection(enableInteractiveSelection) + ..hasFocus = hasFocus + ..setDevicePixelRatio(devicePixelRatio) + ..setCursorCont(cursorCont); + } + + EdgeInsetsGeometry _getPadding() { + return EdgeInsetsDirectional.only( + start: indentWidth, + top: verticalSpacing.item1, + bottom: verticalSpacing.item2); + } +} + +enum TextLineSlot { LEADING, BODY } + +class RenderEditableTextLine extends RenderEditableBox { + RenderEditableTextLine( + this.line, + this.textDirection, + this.textSelection, + this.enableInteractiveSelection, + this.hasFocus, + this.devicePixelRatio, + this.padding, + this.color, + this.cursorCont, + ); + + RenderBox? _leading; + RenderContentProxyBox? _body; + Line line; + TextDirection textDirection; + TextSelection textSelection; + Color color; + bool enableInteractiveSelection; + bool hasFocus = false; + double devicePixelRatio; + EdgeInsetsGeometry padding; + CursorCont cursorCont; + EdgeInsets? _resolvedPadding; + bool? _containsCursor; + List? _selectedRects; + Rect? _caretPrototype; + final Map children = {}; + + Iterable get _children sync* { + if (_leading != null) { + yield _leading!; + } + if (_body != null) { + yield _body!; + } + } + + void setCursorCont(CursorCont c) { + if (cursorCont == c) { + return; + } + cursorCont = c; + markNeedsLayout(); + } + + void setDevicePixelRatio(double d) { + if (devicePixelRatio == d) { + return; + } + devicePixelRatio = d; + markNeedsLayout(); + } + + void setEnableInteractiveSelection(bool val) { + if (enableInteractiveSelection == val) { + return; + } + + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + void setColor(Color c) { + if (color == c) { + return; + } + + color = c; + if (containsTextSelection()) { + markNeedsPaint(); + } + } + + void setTextSelection(TextSelection t) { + if (textSelection == t) { + return; + } + + final containsSelection = containsTextSelection(); + if (attached && containsCursor()) { + cursorCont.removeListener(markNeedsLayout); + cursorCont.color.removeListener(markNeedsPaint); + } + + textSelection = t; + _selectedRects = null; + _containsCursor = null; + if (attached && containsCursor()) { + cursorCont.addListener(markNeedsLayout); + cursorCont.color.addListener(markNeedsPaint); + } + + if (containsSelection || containsTextSelection()) { + markNeedsPaint(); + } + } + + void setTextDirection(TextDirection t) { + if (textDirection == t) { + return; + } + textDirection = t; + _resolvedPadding = null; + markNeedsLayout(); + } + + void setLine(Line l) { + if (line == l) { + return; + } + line = l; + _containsCursor = null; + markNeedsLayout(); + } + + void setPadding(EdgeInsetsGeometry p) { + assert(p.isNonNegative); + if (padding == p) { + return; + } + padding = p; + _resolvedPadding = null; + markNeedsLayout(); + } + + void setLeading(RenderBox? l) { + _leading = _updateChild(_leading, l, TextLineSlot.LEADING); + } + + void setBody(RenderContentProxyBox? b) { + _body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?; + } + + bool containsTextSelection() { + return line.documentOffset <= textSelection.end && + textSelection.start <= line.documentOffset + line.length - 1; + } + + bool containsCursor() { + return _containsCursor ??= textSelection.isCollapsed && + line.containsOffset(textSelection.baseOffset); + } + + RenderBox? _updateChild( + RenderBox? old, RenderBox? newChild, TextLineSlot slot) { + if (old != null) { + dropChild(old); + children.remove(slot); + } + if (newChild != null) { + children[slot] = newChild; + adoptChild(newChild); + } + return newChild; + } + + List _getBoxes(TextSelection textSelection) { + final parentData = _body!.parentData as BoxParentData?; + return _body!.getBoxesForSelection(textSelection).map((box) { + return TextBox.fromLTRBD( + box.left + parentData!.offset.dx, + box.top + parentData.offset.dy, + box.right + parentData.offset.dx, + box.bottom + parentData.offset.dy, + box.direction, + ); + }).toList(growable: false); + } + + void _resolvePadding() { + if (_resolvedPadding != null) { + return; + } + _resolvedPadding = padding.resolve(textDirection); + assert(_resolvedPadding!.isNonNegative); + } + + @override + TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) { + return _getEndpointForSelection(textSelection, true); + } + + @override + TextSelectionPoint getExtentEndpointForSelection( + TextSelection textSelection) { + return _getEndpointForSelection(textSelection, false); + } + + TextSelectionPoint _getEndpointForSelection( + TextSelection textSelection, bool first) { + if (textSelection.isCollapsed) { + return TextSelectionPoint( + Offset(0, preferredLineHeight(textSelection.extent)) + + getOffsetForCaret(textSelection.extent), + null); + } + final boxes = _getBoxes(textSelection); + assert(boxes.isNotEmpty); + final targetBox = first ? boxes.first : boxes.last; + return TextSelectionPoint( + Offset(first ? targetBox.start : targetBox.end, targetBox.bottom), + targetBox.direction); + } + + @override + TextRange getLineBoundary(TextPosition position) { + final lineDy = getOffsetForCaret(position) + .translate(0, 0.5 * preferredLineHeight(position)) + .dy; + final lineBoxes = + _getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1)) + .where((element) => element.top < lineDy && element.bottom > lineDy) + .toList(growable: false); + return TextRange( + start: + getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset, + end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset); + } + + @override + Offset getOffsetForCaret(TextPosition position) { + return _body!.getOffsetForCaret(position, _caretPrototype) + + (_body!.parentData as BoxParentData).offset; + } + + @override + TextPosition? getPositionAbove(TextPosition position) { + return _getPosition(position, -0.5); + } + + @override + TextPosition? getPositionBelow(TextPosition position) { + return _getPosition(position, 1.5); + } + + TextPosition? _getPosition(TextPosition textPosition, double dyScale) { + assert(textPosition.offset < line.length); + final offset = getOffsetForCaret(textPosition) + .translate(0, dyScale * preferredLineHeight(textPosition)); + if (_body!.size + .contains(offset - (_body!.parentData as BoxParentData).offset)) { + return getPositionForOffset(offset); + } + return null; + } + + @override + TextPosition getPositionForOffset(Offset offset) { + return _body!.getPositionForOffset( + offset - (_body!.parentData as BoxParentData).offset); + } + + @override + TextRange getWordBoundary(TextPosition position) { + return _body!.getWordBoundary(position); + } + + @override + double preferredLineHeight(TextPosition position) { + return _body!.getPreferredLineHeight(); + } + + @override + container.Container getContainer() { + return line; + } + + double get cursorWidth => cursorCont.style.width; + + double get cursorHeight => + cursorCont.style.height ?? + preferredLineHeight(const TextPosition(offset: 0)); + + void _computeCaretPrototype() { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0); + break; + default: + throw 'Invalid platform'; + } + } + + @override + void attach(covariant PipelineOwner owner) { + super.attach(owner); + for (final child in _children) { + child.attach(owner); + } + if (containsCursor()) { + cursorCont.addListener(markNeedsLayout); + cursorCont.cursorColor.addListener(markNeedsPaint); + } + } + + @override + void detach() { + super.detach(); + for (final child in _children) { + child.detach(); + } + if (containsCursor()) { + cursorCont.removeListener(markNeedsLayout); + cursorCont.cursorColor.removeListener(markNeedsPaint); + } + } + + @override + void redepthChildren() { + _children.forEach(redepthChild); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + _children.forEach(visitor); + } + + @override + List debugDescribeChildren() { + final value = []; + void add(RenderBox? child, String name) { + if (child != null) { + value.add(child.toDiagnosticsNode(name: name)); + } + } + + add(_leading, 'leading'); + add(_body, 'body'); + return value; + } + + @override + bool get sizedByParent => false; + + @override + double computeMinIntrinsicWidth(double height) { + _resolvePadding(); + final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; + final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; + final leadingWidth = _leading == null + ? 0 + : _leading!.getMinIntrinsicWidth(height - verticalPadding) as int; + final bodyWidth = _body == null + ? 0 + : _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) + as int; + return horizontalPadding + leadingWidth + bodyWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + _resolvePadding(); + final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; + final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; + final leadingWidth = _leading == null + ? 0 + : _leading!.getMaxIntrinsicWidth(height - verticalPadding) as int; + final bodyWidth = _body == null + ? 0 + : _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) + as int; + return horizontalPadding + leadingWidth + bodyWidth; + } + + @override + double computeMinIntrinsicHeight(double width) { + _resolvePadding(); + final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; + final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; + if (_body != null) { + return _body! + .getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) + + verticalPadding; + } + return verticalPadding; + } + + @override + double computeMaxIntrinsicHeight(double width) { + _resolvePadding(); + final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; + final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; + if (_body != null) { + return _body! + .getMaxIntrinsicHeight(math.max(0, width - horizontalPadding)) + + verticalPadding; + } + return verticalPadding; + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + _resolvePadding(); + return _body!.getDistanceToActualBaseline(baseline)! + + _resolvedPadding!.top; + } + + @override + void performLayout() { + final constraints = this.constraints; + _selectedRects = null; + + _resolvePadding(); + assert(_resolvedPadding != null); + + if (_body == null && _leading == null) { + size = constraints.constrain(Size( + _resolvedPadding!.left + _resolvedPadding!.right, + _resolvedPadding!.top + _resolvedPadding!.bottom, + )); + return; + } + final innerConstraints = constraints.deflate(_resolvedPadding!); + + final indentWidth = textDirection == TextDirection.ltr + ? _resolvedPadding!.left + : _resolvedPadding!.right; + + _body!.layout(innerConstraints, parentUsesSize: true); + (_body!.parentData as BoxParentData).offset = + Offset(_resolvedPadding!.left, _resolvedPadding!.top); + + if (_leading != null) { + final leadingConstraints = innerConstraints.copyWith( + minWidth: indentWidth, + maxWidth: indentWidth, + maxHeight: _body!.size.height); + _leading!.layout(leadingConstraints, parentUsesSize: true); + (_leading!.parentData as BoxParentData).offset = + Offset(0, _resolvedPadding!.top); + } + + size = constraints.constrain(Size( + _resolvedPadding!.left + _body!.size.width + _resolvedPadding!.right, + _resolvedPadding!.top + _body!.size.height + _resolvedPadding!.bottom, + )); + + _computeCaretPrototype(); + } + + CursorPainter get _cursorPainter => CursorPainter( + _body, + cursorCont.style, + _caretPrototype, + cursorCont.cursorColor.value, + devicePixelRatio, + ); + + @override + void paint(PaintingContext context, Offset offset) { + if (_leading != null) { + final parentData = _leading!.parentData as BoxParentData; + final effectiveOffset = offset + parentData.offset; + context.paintChild(_leading!, effectiveOffset); + } + + if (_body != null) { + final parentData = _body!.parentData as BoxParentData; + final effectiveOffset = offset + parentData.offset; + if (enableInteractiveSelection && + line.documentOffset <= textSelection.end && + textSelection.start <= line.documentOffset + line.length - 1) { + final local = localSelection(line, textSelection, false); + _selectedRects ??= _body!.getBoxesForSelection( + local, + ); + _paintSelection(context, effectiveOffset); + } + + if (hasFocus && + cursorCont.show.value && + containsCursor() && + !cursorCont.style.paintAboveText) { + _paintCursor(context, effectiveOffset); + } + + context.paintChild(_body!, effectiveOffset); + + if (hasFocus && + cursorCont.show.value && + containsCursor() && + cursorCont.style.paintAboveText) { + _paintCursor(context, effectiveOffset); + } + } + } + + void _paintSelection(PaintingContext context, Offset effectiveOffset) { + assert(_selectedRects != null); + final paint = Paint()..color = color; + for (final box in _selectedRects!) { + context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint); + } + } + + void _paintCursor(PaintingContext context, Offset effectiveOffset) { + final position = TextPosition( + offset: textSelection.extentOffset - line.documentOffset, + affinity: textSelection.base.affinity, + ); + _cursorPainter.paint(context.canvas, effectiveOffset, position); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return _children.first.hitTest(result, position: position); + } +} + +class _TextLineElement extends RenderObjectElement { + _TextLineElement(EditableTextLine line) : super(line); + + final Map _slotToChildren = {}; + + @override + EditableTextLine get widget => super.widget as EditableTextLine; + + @override + RenderEditableTextLine get renderObject => + super.renderObject as RenderEditableTextLine; + + @override + void visitChildren(ElementVisitor visitor) { + _slotToChildren.values.forEach(visitor); + } + + @override + void forgetChild(Element child) { + assert(_slotToChildren.containsValue(child)); + assert(child.slot is TextLineSlot); + assert(_slotToChildren.containsKey(child.slot)); + _slotToChildren.remove(child.slot); + super.forgetChild(child); + } + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + _mountChild(widget.leading, TextLineSlot.LEADING); + _mountChild(widget.body, TextLineSlot.BODY); + } + + @override + void update(EditableTextLine newWidget) { + super.update(newWidget); + assert(widget == newWidget); + _updateChild(widget.leading, TextLineSlot.LEADING); + _updateChild(widget.body, TextLineSlot.BODY); + } + + @override + void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) { + // assert(child is RenderBox); + _updateRenderObject(child, slot); + assert(renderObject.children.keys.contains(slot)); + } + + @override + void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) { + assert(child is RenderBox); + assert(renderObject.children[slot!] == child); + _updateRenderObject(null, slot); + assert(!renderObject.children.keys.contains(slot)); + } + + @override + void moveRenderObjectChild( + RenderObject child, dynamic oldSlot, dynamic newSlot) { + throw UnimplementedError(); + } + + void _mountChild(Widget? widget, TextLineSlot slot) { + final oldChild = _slotToChildren[slot]; + final newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + _slotToChildren.remove(slot); + } + if (newChild != null) { + _slotToChildren[slot] = newChild; + } + } + + void _updateRenderObject(RenderBox? child, TextLineSlot? slot) { + switch (slot) { + case TextLineSlot.LEADING: + renderObject.setLeading(child); + break; + case TextLineSlot.BODY: + renderObject.setBody(child as RenderContentProxyBox?); + break; + default: + throw UnimplementedError(); + } + } + + void _updateChild(Widget? widget, TextLineSlot slot) { + final oldChild = _slotToChildren[slot]; + final newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + _slotToChildren.remove(slot); + } + if (newChild != null) { + _slotToChildren[slot] = newChild; + } + } +} diff --git a/lib/src/widgets/text_selection.dart b/lib/src/widgets/text_selection.dart new file mode 100644 index 00000000..a8748de1 --- /dev/null +++ b/lib/src/widgets/text_selection.dart @@ -0,0 +1,726 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; + +import '../models/documents/nodes/node.dart'; +import 'editor.dart'; + +TextSelection localSelection(Node node, TextSelection selection, fromParent) { + final base = fromParent ? node.offset : node.documentOffset; + assert(base <= selection.end && selection.start <= base + node.length - 1); + + final offset = fromParent ? node.offset : node.documentOffset; + return selection.copyWith( + baseOffset: math.max(selection.start - offset, 0), + extentOffset: math.min(selection.end - offset, node.length - 1)); +} + +enum _TextSelectionHandlePosition { START, END } + +class EditorTextSelectionOverlay { + 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, + ) { + final overlay = Overlay.of(context, rootOverlay: true)!; + + _toolbarController = AnimationController( + duration: const Duration(milliseconds: 150), vsync: overlay); + } + + 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; + late AnimationController _toolbarController; + List? _handles; + OverlayEntry? toolbar; + + TextSelection get _selection => value.selection; + + Animation get _toolbarOpacity => _toolbarController.view; + + void setHandlesVisible(bool visible) { + if (handlesVisible == visible) { + return; + } + handlesVisible = visible; + if (SchedulerBinding.instance!.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); + } else { + markNeedsBuild(); + } + } + + void hideHandles() { + if (_handles == null) { + return; + } + _handles![0].remove(); + _handles![1].remove(); + _handles = null; + } + + void hideToolbar() { + assert(toolbar != null); + _toolbarController.stop(); + toolbar!.remove(); + toolbar = null; + } + + void showToolbar() { + assert(toolbar == null); + toolbar = OverlayEntry(builder: _buildToolbar); + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! + .insert(toolbar!); + _toolbarController.forward(from: 0); + } + + Widget _buildHandle( + BuildContext context, _TextSelectionHandlePosition position) { + if (_selection.isCollapsed && + position == _TextSelectionHandlePosition.END) { + return Container(); + } + return Visibility( + visible: handlesVisible, + child: _TextSelectionHandleOverlay( + onSelectionHandleChanged: (newSelection) { + _handleSelectionHandleChanged(newSelection, position); + }, + onSelectionHandleTapped: onSelectionHandleTapped, + startHandleLayerLink: startHandleLayerLink, + endHandleLayerLink: endHandleLayerLink, + renderObject: renderObject, + selection: _selection, + selectionControls: selectionCtrls, + position: position, + dragStartBehavior: dragStartBehavior, + )); + } + + void update(TextEditingValue newValue) { + if (value == newValue) { + return; + } + value = newValue; + if (SchedulerBinding.instance!.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); + } else { + markNeedsBuild(); + } + } + + void _handleSelectionHandleChanged( + TextSelection? newSelection, _TextSelectionHandlePosition position) { + TextPosition textPosition; + switch (position) { + case _TextSelectionHandlePosition.START: + textPosition = newSelection != null + ? newSelection.base + : const TextPosition(offset: 0); + break; + case _TextSelectionHandlePosition.END: + textPosition = newSelection != null + ? newSelection.extent + : const TextPosition(offset: 0); + break; + default: + throw 'Invalid position'; + } + selectionDelegate + ..textEditingValue = + value.copyWith(selection: newSelection, composing: TextRange.empty) + ..bringIntoView(textPosition); + } + + Widget _buildToolbar(BuildContext context) { + final endpoints = renderObject!.getEndpointsForSelection(_selection); + + final editingRegion = Rect.fromPoints( + renderObject!.localToGlobal(Offset.zero), + renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)), + ); + + final baseLineHeight = renderObject!.preferredLineHeight(_selection.base); + final extentLineHeight = + renderObject!.preferredLineHeight(_selection.extent); + final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); + final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > + smallestLineHeight / 2; + + final midX = isMultiline + ? editingRegion.width / 2 + : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; + + final midpoint = Offset( + midX, + endpoints[0].point.dy - baseLineHeight, + ); + + return FadeTransition( + opacity: _toolbarOpacity, + child: CompositedTransformFollower( + link: toolbarLayerLink, + showWhenUnlinked: false, + offset: -editingRegion.topLeft, + child: selectionCtrls.buildToolbar( + context, + editingRegion, + baseLineHeight, + midpoint, + endpoints, + selectionDelegate, + clipboardStatus, + const Offset(0, 0)), + ), + ); + } + + void markNeedsBuild([Duration? duration]) { + if (_handles != null) { + _handles![0].markNeedsBuild(); + _handles![1].markNeedsBuild(); + } + toolbar?.markNeedsBuild(); + } + + void hide() { + if (_handles != null) { + _handles![0].remove(); + _handles![1].remove(); + _handles = null; + } + if (toolbar != null) { + hideToolbar(); + } + } + + void dispose() { + hide(); + _toolbarController.dispose(); + } + + void showHandles() { + assert(_handles == null); + _handles = [ + OverlayEntry( + builder: (context) => + _buildHandle(context, _TextSelectionHandlePosition.START)), + OverlayEntry( + builder: (context) => + _buildHandle(context, _TextSelectionHandlePosition.END)), + ]; + + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! + .insertAll(_handles!); + } +} + +class _TextSelectionHandleOverlay extends StatefulWidget { + const _TextSelectionHandleOverlay({ + required this.selection, + required this.position, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.renderObject, + required this.onSelectionHandleChanged, + required this.onSelectionHandleTapped, + required this.selectionControls, + this.dragStartBehavior = DragStartBehavior.start, + Key? key, + }) : super(key: key); + + final TextSelection selection; + final _TextSelectionHandlePosition position; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final RenderEditor? renderObject; + final ValueChanged onSelectionHandleChanged; + final VoidCallback? onSelectionHandleTapped; + final TextSelectionControls selectionControls; + final DragStartBehavior dragStartBehavior; + + @override + _TextSelectionHandleOverlayState createState() => + _TextSelectionHandleOverlayState(); + + ValueListenable? get _visibility { + switch (position) { + case _TextSelectionHandlePosition.START: + return renderObject!.selectionStartInViewport; + case _TextSelectionHandlePosition.END: + return renderObject!.selectionEndInViewport; + } + } +} + +class _TextSelectionHandleOverlayState + extends State<_TextSelectionHandleOverlay> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 150), vsync: this); + + _handleVisibilityChanged(); + widget._visibility!.addListener(_handleVisibilityChanged); + } + + void _handleVisibilityChanged() { + if (widget._visibility!.value) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget._visibility!.removeListener(_handleVisibilityChanged); + _handleVisibilityChanged(); + widget._visibility!.addListener(_handleVisibilityChanged); + } + + @override + void dispose() { + widget._visibility!.removeListener(_handleVisibilityChanged); + _controller.dispose(); + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) {} + + void _handleDragUpdate(DragUpdateDetails details) { + final position = + widget.renderObject!.getPositionForOffset(details.globalPosition); + if (widget.selection.isCollapsed) { + widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); + return; + } + + final isNormalized = + widget.selection.extentOffset >= widget.selection.baseOffset; + TextSelection? newSelection; + switch (widget.position) { + case _TextSelectionHandlePosition.START: + newSelection = TextSelection( + baseOffset: + isNormalized ? position.offset : widget.selection.baseOffset, + extentOffset: + isNormalized ? widget.selection.extentOffset : position.offset, + ); + break; + case _TextSelectionHandlePosition.END: + newSelection = TextSelection( + baseOffset: + isNormalized ? widget.selection.baseOffset : position.offset, + extentOffset: + isNormalized ? position.offset : widget.selection.extentOffset, + ); + break; + } + + widget.onSelectionHandleChanged(newSelection); + } + + void _handleTap() { + if (widget.onSelectionHandleTapped != null) { + widget.onSelectionHandleTapped!(); + } + } + + @override + Widget build(BuildContext context) { + late LayerLink layerLink; + TextSelectionHandleType? type; + + switch (widget.position) { + case _TextSelectionHandlePosition.START: + layerLink = widget.startHandleLayerLink; + type = _chooseType( + widget.renderObject!.textDirection, + TextSelectionHandleType.left, + TextSelectionHandleType.right, + ); + break; + case _TextSelectionHandlePosition.END: + assert(!widget.selection.isCollapsed); + layerLink = widget.endHandleLayerLink; + type = _chooseType( + widget.renderObject!.textDirection, + TextSelectionHandleType.right, + TextSelectionHandleType.left, + ); + break; + } + + final textPosition = widget.position == _TextSelectionHandlePosition.START + ? widget.selection.base + : widget.selection.extent; + final lineHeight = widget.renderObject!.preferredLineHeight(textPosition); + final handleAnchor = + widget.selectionControls.getHandleAnchor(type!, lineHeight); + final handleSize = widget.selectionControls.getHandleSize(lineHeight); + + final handleRect = Rect.fromLTWH( + -handleAnchor.dx, + -handleAnchor.dy, + handleSize.width, + handleSize.height, + ); + + final interactiveRect = handleRect.expandToInclude( + Rect.fromCircle( + center: handleRect.center, radius: kMinInteractiveDimension / 2), + ); + final padding = RelativeRect.fromLTRB( + math.max((interactiveRect.width - handleRect.width) / 2, 0), + math.max((interactiveRect.height - handleRect.height) / 2, 0), + math.max((interactiveRect.width - handleRect.width) / 2, 0), + math.max((interactiveRect.height - handleRect.height) / 2, 0), + ); + + return CompositedTransformFollower( + link: layerLink, + offset: interactiveRect.topLeft, + showWhenUnlinked: false, + child: FadeTransition( + opacity: _opacity, + child: Container( + alignment: Alignment.topLeft, + width: interactiveRect.width, + height: interactiveRect.height, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + dragStartBehavior: widget.dragStartBehavior, + onPanStart: _handleDragStart, + onPanUpdate: _handleDragUpdate, + onTap: _handleTap, + child: Padding( + padding: EdgeInsets.only( + left: padding.left, + top: padding.top, + right: padding.right, + bottom: padding.bottom, + ), + child: widget.selectionControls.buildHandle( + context, + type, + lineHeight, + ), + ), + ), + ), + ), + ); + } + + TextSelectionHandleType? _chooseType( + TextDirection textDirection, + TextSelectionHandleType ltrType, + TextSelectionHandleType rtlType, + ) { + if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; + + switch (textDirection) { + case TextDirection.ltr: + return ltrType; + case TextDirection.rtl: + return rtlType; + } + } +} + +class EditorTextSelectionGestureDetector extends StatefulWidget { + const EditorTextSelectionGestureDetector({ + required this.child, + this.onTapDown, + this.onForcePressStart, + this.onForcePressEnd, + this.onSingleTapUp, + this.onSingleTapCancel, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.onDoubleTapDown, + this.onDragSelectionStart, + this.onDragSelectionUpdate, + this.onDragSelectionEnd, + this.behavior, + Key? key, + }) : super(key: key); + + final GestureTapDownCallback? onTapDown; + + final GestureForcePressStartCallback? onForcePressStart; + + final GestureForcePressEndCallback? onForcePressEnd; + + final GestureTapUpCallback? onSingleTapUp; + + final GestureTapCancelCallback? onSingleTapCancel; + + final GestureLongPressStartCallback? onSingleLongTapStart; + + final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; + + final GestureLongPressEndCallback? onSingleLongTapEnd; + + final GestureTapDownCallback? onDoubleTapDown; + + final GestureDragStartCallback? onDragSelectionStart; + + final DragSelectionUpdateCallback? onDragSelectionUpdate; + + final GestureDragEndCallback? onDragSelectionEnd; + + final HitTestBehavior? behavior; + + final Widget child; + + @override + State createState() => + _EditorTextSelectionGestureDetectorState(); +} + +class _EditorTextSelectionGestureDetectorState + extends State { + Timer? _doubleTapTimer; + Offset? _lastTapOffset; + bool _isDoubleTap = false; + + @override + void dispose() { + _doubleTapTimer?.cancel(); + _dragUpdateThrottleTimer?.cancel(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + // renderObject.resetTapDownStatus(); + if (widget.onTapDown != null) { + widget.onTapDown!(details); + } + if (_doubleTapTimer != null && + _isWithinDoubleTapTolerance(details.globalPosition)) { + if (widget.onDoubleTapDown != null) { + widget.onDoubleTapDown!(details); + } + + _doubleTapTimer!.cancel(); + _doubleTapTimeout(); + _isDoubleTap = true; + } + } + + void _handleTapUp(TapUpDetails details) { + if (!_isDoubleTap) { + if (widget.onSingleTapUp != null) { + widget.onSingleTapUp!(details); + } + _lastTapOffset = details.globalPosition; + _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); + } + _isDoubleTap = false; + } + + void _handleTapCancel() { + if (widget.onSingleTapCancel != null) { + widget.onSingleTapCancel!(); + } + } + + DragStartDetails? _lastDragStartDetails; + DragUpdateDetails? _lastDragUpdateDetails; + Timer? _dragUpdateThrottleTimer; + + void _handleDragStart(DragStartDetails details) { + assert(_lastDragStartDetails == null); + _lastDragStartDetails = details; + if (widget.onDragSelectionStart != null) { + widget.onDragSelectionStart!(details); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + _lastDragUpdateDetails = details; + _dragUpdateThrottleTimer ??= + Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled); + } + + void _handleDragUpdateThrottled() { + assert(_lastDragStartDetails != null); + assert(_lastDragUpdateDetails != null); + if (widget.onDragSelectionUpdate != null) { + widget.onDragSelectionUpdate!( + _lastDragStartDetails!, _lastDragUpdateDetails!); + } + _dragUpdateThrottleTimer = null; + _lastDragUpdateDetails = null; + } + + void _handleDragEnd(DragEndDetails details) { + assert(_lastDragStartDetails != null); + if (_dragUpdateThrottleTimer != null) { + _dragUpdateThrottleTimer!.cancel(); + _handleDragUpdateThrottled(); + } + if (widget.onDragSelectionEnd != null) { + widget.onDragSelectionEnd!(details); + } + _dragUpdateThrottleTimer = null; + _lastDragStartDetails = null; + _lastDragUpdateDetails = null; + } + + void _forcePressStarted(ForcePressDetails details) { + _doubleTapTimer?.cancel(); + _doubleTapTimer = null; + if (widget.onForcePressStart != null) { + widget.onForcePressStart!(details); + } + } + + void _forcePressEnded(ForcePressDetails details) { + if (widget.onForcePressEnd != null) { + widget.onForcePressEnd!(details); + } + } + + void _handleLongPressStart(LongPressStartDetails details) { + if (!_isDoubleTap && widget.onSingleLongTapStart != null) { + widget.onSingleLongTapStart!(details); + } + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { + widget.onSingleLongTapMoveUpdate!(details); + } + } + + void _handleLongPressEnd(LongPressEndDetails details) { + if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { + widget.onSingleLongTapEnd!(details); + } + _isDoubleTap = false; + } + + void _doubleTapTimeout() { + _doubleTapTimer = null; + _lastTapOffset = null; + } + + bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { + if (_lastTapOffset == null) { + return false; + } + + return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop; + } + + @override + Widget build(BuildContext context) { + final gestures = {}; + + gestures[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (instance) { + instance + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onTapCancel = _handleTapCancel; + }, + ); + + if (widget.onSingleLongTapStart != null || + widget.onSingleLongTapMoveUpdate != null || + widget.onSingleLongTapEnd != null) { + gestures[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, kind: PointerDeviceKind.touch), + (instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + } + + if (widget.onDragSelectionStart != null || + widget.onDragSelectionUpdate != null || + widget.onDragSelectionEnd != null) { + gestures[HorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => HorizontalDragGestureRecognizer( + debugOwner: this, kind: PointerDeviceKind.mouse), + (instance) { + instance + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd; + }, + ); + } + + if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { + gestures[ForcePressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => ForcePressGestureRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = + widget.onForcePressStart != null ? _forcePressStarted : null + ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; + }, + ); + } + + return RawGestureDetector( + gestures: gestures, + excludeFromSemantics: true, + behavior: widget.behavior, + child: widget.child, + ); + } +} diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart new file mode 100644 index 00000000..c5fe2bab --- /dev/null +++ b/lib/src/widgets/toolbar.dart @@ -0,0 +1,1294 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:filesystem_picker/filesystem_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/nodes/embed.dart'; +import '../models/documents/style.dart'; +import '../utils/color.dart'; +import 'controller.dart'; + +typedef OnImagePickCallback = Future Function(File file); +typedef ImagePickImpl = Future Function(ImageSource source); + +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) { + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: controller.iconSize * 1.77, + icon: Icon( + icon, + size: controller.iconSize, + color: Theme.of(context).iconTheme.color, + ), + fillColor: fillColor ?? Theme.of(context).canvasColor, + onPressed: () { + final index = controller.selection.baseOffset; + final length = controller.selection.extentOffset - index; + controller.replaceText(index, length, BlockEmbed.horizontalRule, null); + }, + ); + } +} + +class LinkStyleButton extends StatefulWidget { + const LinkStyleButton({ + required this.controller, + this.icon, + Key? key, + }) : super(key: key); + + final QuillController controller; + final IconData? icon; + + @override + _LinkStyleButtonState createState() => _LinkStyleButtonState(); +} + +class _LinkStyleButtonState extends State { + void _didChangeSelection() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_didChangeSelection); + } + + @override + void didUpdateWidget(covariant LinkStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeSelection); + widget.controller.addListener(_didChangeSelection); + } + } + + @override + void dispose() { + super.dispose(); + widget.controller.removeListener(_didChangeSelection); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEnabled = !widget.controller.selection.isCollapsed; + final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.controller.iconSize * 1.77, + icon: Icon( + widget.icon ?? Icons.link, + size: widget.controller.iconSize, + color: isEnabled ? theme.iconTheme.color : theme.disabledColor, + ), + fillColor: Theme.of(context).canvasColor, + onPressed: pressedHandler, + ); + } + + void _openLinkDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) { + return const _LinkDialog(); + }, + ).then(_linkSubmitted); + } + + void _linkSubmitted(String? value) { + if (value == null || value.isEmpty) { + return; + } + widget.controller.formatSelection(LinkAttribute(value)); + } +} + +class _LinkDialog extends StatefulWidget { + const _LinkDialog({Key? key}) : super(key: key); + + @override + _LinkDialogState createState() => _LinkDialogState(); +} + +class _LinkDialogState extends State<_LinkDialog> { + String _link = ''; + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: TextField( + decoration: const InputDecoration(labelText: 'Paste a link'), + autofocus: true, + onChanged: _linkChanged, + ), + actions: [ + TextButton( + onPressed: _link.isNotEmpty ? _applyLink : null, + child: const Text('Apply'), + ), + ], + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + void _applyLink() { + Navigator.pop(context, _link); + } +} + +typedef ToggleStyleButtonBuilder = Widget Function( + BuildContext context, + Attribute attribute, + IconData icon, + Color? fillColor, + bool? isToggled, + VoidCallback? onPressed, +); + +class ToggleStyleButton extends StatefulWidget { + const ToggleStyleButton({ + required this.attribute, + required this.icon, + required this.controller, + this.fillColor, + this.childBuilder = defaultToggleStyleButtonBuilder, + Key? key, + }) : super(key: key); + + final Attribute attribute; + + final IconData icon; + + final Color? fillColor; + + final QuillController controller; + + final ToggleStyleButtonBuilder childBuilder; + + @override + _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); +} + +class _ToggleStyleButtonState extends State { + bool? _isToggled; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _isToggled = + _getIsToggled(widget.controller.getSelectionStyle().attributes); + }); + } + + @override + void initState() { + super.initState(); + _isToggled = _getIsToggled(_selectionStyle.attributes); + widget.controller.addListener(_didChangeEditingValue); + } + + bool _getIsToggled(Map attrs) { + if (widget.attribute.key == Attribute.list.key) { + final attribute = attrs[widget.attribute.key]; + if (attribute == null) { + return false; + } + return attribute.value == widget.attribute.value; + } + return attrs.containsKey(widget.attribute.key); + } + + @override + void didUpdateWidget(covariant ToggleStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggled = _getIsToggled(_selectionStyle.attributes); + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isInCodeBlock = + _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); + final isEnabled = + !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; + return widget.childBuilder(context, widget.attribute, widget.icon, + widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); + } + + void _toggleAttribute() { + widget.controller.formatSelection(_isToggled! + ? Attribute.clone(widget.attribute, null) + : widget.attribute); + } +} + +class ToggleCheckListButton extends StatefulWidget { + const ToggleCheckListButton({ + 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; + + final Attribute attribute; + + @override + _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); +} + +class _ToggleCheckListButtonState extends State { + bool? _isToggled; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _isToggled = + _getIsToggled(widget.controller.getSelectionStyle().attributes); + }); + } + + @override + void initState() { + super.initState(); + _isToggled = _getIsToggled(_selectionStyle.attributes); + widget.controller.addListener(_didChangeEditingValue); + } + + bool _getIsToggled(Map attrs) { + if (widget.attribute.key == Attribute.list.key) { + final attribute = attrs[widget.attribute.key]; + if (attribute == null) { + return false; + } + return attribute.value == widget.attribute.value || + attribute.value == Attribute.checked.value; + } + return attrs.containsKey(widget.attribute.key); + } + + @override + void didUpdateWidget(covariant ToggleCheckListButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggled = _getIsToggled(_selectionStyle.attributes); + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isInCodeBlock = + _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); + final isEnabled = + !isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; + return widget.childBuilder(context, Attribute.unchecked, widget.icon, + widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); + } + + void _toggleAttribute() { + widget.controller.formatSelection(_isToggled! + ? Attribute.clone(Attribute.unchecked, null) + : Attribute.unchecked); + } +} + +Widget defaultToggleStyleButtonBuilder( + BuildContext context, + Attribute attribute, + IconData icon, + Color? fillColor, + bool? isToggled, + VoidCallback? onPressed, +) { + final theme = Theme.of(context); + final isEnabled = onPressed != null; + final iconColor = isEnabled + ? isToggled == true + ? theme.primaryIconTheme.color + : theme.iconTheme.color + : theme.disabledColor; + final fill = isToggled == true + ? theme.toggleableActiveColor + : fillColor ?? theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: 18 * 1.77, + icon: Icon(icon, size: 18, color: iconColor), + fillColor: fill, + onPressed: onPressed, + ); +} + +class SelectHeaderStyleButton extends StatefulWidget { + const SelectHeaderStyleButton({required this.controller, Key? key}) + : super(key: key); + + final QuillController controller; + + @override + _SelectHeaderStyleButtonState createState() => + _SelectHeaderStyleButtonState(); +} + +class _SelectHeaderStyleButtonState extends State { + Attribute? _value; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + }); + } + + void _selectAttribute(value) { + widget.controller.formatSelection(value); + } + + @override + void initState() { + super.initState(); + setState(() { + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + }); + widget.controller.addListener(_didChangeEditingValue); + } + + @override + void didUpdateWidget(covariant SelectHeaderStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _selectHeadingStyleButtonBuilder( + context, _value, _selectAttribute, widget.controller.iconSize); + } +} + +Widget _selectHeadingStyleButtonBuilder(BuildContext context, Attribute? value, + ValueChanged onSelected, double iconSize) { + final _valueToText = { + Attribute.header: 'N', + Attribute.h1: 'H1', + Attribute.h2: 'H2', + Attribute.h3: 'H3', + }; + + final _valueAttribute = [ + Attribute.header, + Attribute.h1, + Attribute.h2, + Attribute.h3 + ]; + final _valueString = ['N', 'H1', 'H2', 'H3']; + + final theme = Theme.of(context); + final style = TextStyle( + fontWeight: FontWeight.w600, + fontSize: iconSize * 0.7, + ); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(4, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: iconSize * 1.77, + height: iconSize * 1.77, + ), + child: RawMaterialButton( + hoverElevation: 0, + highlightElevation: 0, + elevation: 0, + visualDensity: VisualDensity.compact, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + fillColor: _valueToText[value] == _valueString[index] + ? theme.toggleableActiveColor + : theme.canvasColor, + onPressed: () { + onSelected(_valueAttribute[index]); + }, + child: Text( + _valueString[index], + style: style.copyWith( + color: _valueToText[value] == _valueString[index] + ? theme.primaryIconTheme.color + : theme.iconTheme.color, + ), + ), + ), + ), + ); + }), + ); +} + +class ImageButton extends StatefulWidget { + const ImageButton({ + required this.icon, + required this.controller, + required this.imageSource, + this.onImagePickCallback, + this.imagePickImpl, + Key? key, + }) : super(key: key); + + final IconData icon; + + final QuillController controller; + + final OnImagePickCallback? onImagePickCallback; + + final ImagePickImpl? imagePickImpl; + + final ImageSource imageSource; + + @override + _ImageButtonState createState() => _ImageButtonState(); +} + +class _ImageButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return QuillIconButton( + icon: Icon( + widget.icon, + size: widget.controller.iconSize, + color: theme.iconTheme.color, + ), + highlightElevation: 0, + hoverElevation: 0, + size: widget.controller.iconSize * 1.77, + fillColor: theme.canvasColor, + onPressed: _handleImageButtonTap, + ); + } + + Future _handleImageButtonTap() async { + final index = widget.controller.selection.baseOffset; + final length = widget.controller.selection.extentOffset - index; + + String? imageUrl; + if (widget.imagePickImpl != null) { + imageUrl = await widget.imagePickImpl!(widget.imageSource); + } else { + if (kIsWeb) { + imageUrl = await _pickImageWeb(); + } else if (Platform.isAndroid || Platform.isIOS) { + imageUrl = await _pickImage(widget.imageSource); + } else { + imageUrl = await _pickImageDesktop(); + } + } + + if (imageUrl != null) { + widget.controller + .replaceText(index, length, BlockEmbed.image(imageUrl), null); + } + } + + Future _pickImageWeb() async { + final result = await FilePicker.platform.pickFiles(); + if (result == null) { + return null; + } + + // Take first, because we don't allow picking multiple files. + final fileName = result.files.first.name!; + final file = File(fileName); + + return widget.onImagePickCallback!(file); + } + + Future _pickImage(ImageSource source) async { + final pickedFile = await ImagePicker().getImage(source: source); + if (pickedFile == null) { + return null; + } + + return widget.onImagePickCallback!(File(pickedFile.path)); + } + + Future _pickImageDesktop() async { + final filePath = await FilesystemPicker.open( + context: context, + rootDirectory: await getApplicationDocumentsDirectory(), + fsType: FilesystemType.file, + fileTileSelectMode: FileTileSelectMode.wholeTile, + ); + if (filePath == null || filePath.isEmpty) return null; + + final file = File(filePath); + return widget.onImagePickCallback!(file); + } +} + +/// Controls color styles. +/// +/// When pressed, this button displays overlay toolbar with +/// buttons for each color. +class ColorButton extends StatefulWidget { + const ColorButton({ + required this.icon, + required this.controller, + required this.background, + Key? key, + }) : super(key: key); + + final IconData icon; + final bool background; + final QuillController controller; + + @override + _ColorButtonState createState() => _ColorButtonState(); +} + +class _ColorButtonState extends State { + late bool _isToggledColor; + late bool _isToggledBackground; + late bool _isWhite; + late bool _isWhitebackground; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _isToggledColor = + _getIsToggledColor(widget.controller.getSelectionStyle().attributes); + _isToggledBackground = _getIsToggledBackground( + widget.controller.getSelectionStyle().attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; + }); + } + + @override + void initState() { + super.initState(); + _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); + _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; + widget.controller.addListener(_didChangeEditingValue); + } + + bool _getIsToggledColor(Map attrs) { + return attrs.containsKey(Attribute.color.key); + } + + bool _getIsToggledBackground(Map attrs) { + return attrs.containsKey(Attribute.background.key); + } + + @override + void didUpdateWidget(covariant ColorButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); + _isToggledBackground = + _getIsToggledBackground(_selectionStyle.attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = _isToggledColor && !widget.background && !_isWhite + ? stringToColor(_selectionStyle.attributes['color']!.value) + : theme.iconTheme.color; + + final iconColorBackground = + _isToggledBackground && widget.background && !_isWhitebackground + ? stringToColor(_selectionStyle.attributes['background']!.value) + : theme.iconTheme.color; + + final fillColor = _isToggledColor && !widget.background && _isWhite + ? stringToColor('#ffffff') + : theme.canvasColor; + final fillColorBackground = + _isToggledBackground && widget.background && _isWhitebackground + ? stringToColor('#ffffff') + : theme.canvasColor; + + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.controller.iconSize * 1.77, + icon: Icon(widget.icon, + size: widget.controller.iconSize, + color: widget.background ? iconColorBackground : iconColor), + fillColor: widget.background ? fillColorBackground : fillColor, + onPressed: _showColorPicker, + ); + } + + void _changeColor(Color color) { + var hex = color.value.toRadixString(16); + if (hex.startsWith('ff')) { + hex = hex.substring(2); + } + hex = '#$hex'; + widget.controller.formatSelection( + widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); + Navigator.of(context).pop(); + } + + void _showColorPicker() { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Select Color'), + backgroundColor: Theme.of(context).canvasColor, + content: SingleChildScrollView( + child: MaterialPicker( + pickerColor: const Color(0x00000000), + onColorChanged: _changeColor, + ), + )), + ); + } +} + +class HistoryButton extends StatefulWidget { + const HistoryButton({ + required this.icon, + required this.controller, + required this.undo, + Key? key, + }) : super(key: key); + + final IconData icon; + final bool undo; + final QuillController controller; + + @override + _HistoryButtonState createState() => _HistoryButtonState(); +} + +class _HistoryButtonState extends State { + Color? _iconColor; + late ThemeData theme; + + @override + Widget build(BuildContext context) { + theme = Theme.of(context); + _setIconColor(); + + final fillColor = theme.canvasColor; + widget.controller.changes.listen((event) async { + _setIconColor(); + }); + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.controller.iconSize * 1.77, + icon: Icon(widget.icon, + size: widget.controller.iconSize, color: _iconColor), + fillColor: fillColor, + onPressed: _changeHistory, + ); + } + + void _setIconColor() { + if (!mounted) return; + + if (widget.undo) { + setState(() { + _iconColor = widget.controller.hasUndo + ? theme.iconTheme.color + : theme.disabledColor; + }); + } else { + setState(() { + _iconColor = widget.controller.hasRedo + ? theme.iconTheme.color + : theme.disabledColor; + }); + } + } + + void _changeHistory() { + if (widget.undo) { + if (widget.controller.hasUndo) { + widget.controller.undo(); + } + } else { + if (widget.controller.hasRedo) { + widget.controller.redo(); + } + } + + _setIconColor(); + } +} + +class IndentButton extends StatefulWidget { + const IndentButton({ + required this.icon, + required this.controller, + required this.isIncrease, + Key? key, + }) : super(key: key); + + final IconData icon; + final QuillController controller; + final bool isIncrease; + + @override + _IndentButtonState createState() => _IndentButtonState(); +} + +class _IndentButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = theme.iconTheme.color; + final fillColor = theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.controller.iconSize * 1.77, + icon: + Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), + fillColor: fillColor, + onPressed: () { + final indent = widget.controller + .getSelectionStyle() + .attributes[Attribute.indent.key]; + if (indent == null) { + if (widget.isIncrease) { + widget.controller.formatSelection(Attribute.indentL1); + } + return; + } + if (indent.value == 1 && !widget.isIncrease) { + widget.controller + .formatSelection(Attribute.clone(Attribute.indentL1, null)); + return; + } + if (widget.isIncrease) { + widget.controller + .formatSelection(Attribute.getIndentLevel(indent.value + 1)); + return; + } + widget.controller + .formatSelection(Attribute.getIndentLevel(indent.value - 1)); + }, + ); + } +} + +class ClearFormatButton extends StatefulWidget { + const ClearFormatButton({ + required this.icon, + required this.controller, + Key? key, + }) : super(key: key); + + final IconData icon; + + final QuillController controller; + + @override + _ClearFormatButtonState createState() => _ClearFormatButtonState(); +} + +class _ClearFormatButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = theme.iconTheme.color; + final fillColor = theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.controller.iconSize * 1.77, + icon: Icon(widget.icon, + size: widget.controller.iconSize, color: iconColor), + fillColor: fillColor, + onPressed: () { + for (final k + in widget.controller.getSelectionStyle().attributes.values) { + widget.controller.formatSelection(Attribute.clone(k, null)); + } + }); + } +} + +class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { + const QuillToolbar( + {required this.children, this.toolBarHeight = 36, Key? key}) + : super(key: key); + + factory QuillToolbar.basic({ + required QuillController controller, + double toolbarIconSize = 18.0, + bool showBoldButton = true, + bool showItalicButton = true, + bool showUnderLineButton = true, + bool showStrikeThrough = true, + bool showColorButton = true, + bool showBackgroundColorButton = true, + bool showClearFormat = true, + bool showHeaderStyle = true, + bool showListNumbers = true, + bool showListBullets = true, + bool showListCheck = true, + bool showCodeBlock = true, + bool showQuote = true, + bool showIndent = true, + bool showLink = true, + bool showHistory = true, + bool showHorizontalRule = false, + OnImagePickCallback? onImagePickCallback, + Key? key, + }) { + controller.iconSize = toolbarIconSize; + + return QuillToolbar( + key: key, + toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor, + children: [ + Visibility( + visible: showHistory, + child: HistoryButton( + icon: Icons.undo_outlined, + controller: controller, + undo: true, + ), + ), + Visibility( + visible: showHistory, + child: HistoryButton( + icon: Icons.redo_outlined, + controller: controller, + undo: false, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showBoldButton, + child: ToggleStyleButton( + attribute: Attribute.bold, + icon: Icons.format_bold, + controller: controller, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showItalicButton, + child: ToggleStyleButton( + attribute: Attribute.italic, + icon: Icons.format_italic, + controller: controller, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showUnderLineButton, + child: ToggleStyleButton( + attribute: Attribute.underline, + icon: Icons.format_underline, + controller: controller, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showStrikeThrough, + child: ToggleStyleButton( + attribute: Attribute.strikeThrough, + icon: Icons.format_strikethrough, + controller: controller, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showColorButton, + child: ColorButton( + icon: Icons.color_lens, + controller: controller, + background: false, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showBackgroundColorButton, + child: ColorButton( + icon: Icons.format_color_fill, + controller: controller, + background: true, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: showClearFormat, + child: ClearFormatButton( + icon: Icons.format_clear, + controller: controller, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: onImagePickCallback != null, + child: ImageButton( + icon: Icons.image, + controller: controller, + imageSource: ImageSource.gallery, + onImagePickCallback: onImagePickCallback, + ), + ), + const SizedBox(width: 0.6), + Visibility( + visible: onImagePickCallback != null, + child: ImageButton( + icon: Icons.photo_camera, + controller: controller, + imageSource: ImageSource.camera, + onImagePickCallback: onImagePickCallback, + ), + ), + Visibility( + visible: showHeaderStyle, + child: VerticalDivider( + indent: 12, endIndent: 12, color: Colors.grey.shade400)), + Visibility( + visible: showHeaderStyle, + child: SelectHeaderStyleButton(controller: controller)), + VerticalDivider( + indent: 12, endIndent: 12, color: Colors.grey.shade400), + Visibility( + visible: showListNumbers, + child: ToggleStyleButton( + attribute: Attribute.ol, + controller: controller, + icon: Icons.format_list_numbered, + ), + ), + Visibility( + visible: showListBullets, + child: ToggleStyleButton( + attribute: Attribute.ul, + controller: controller, + icon: Icons.format_list_bulleted, + ), + ), + Visibility( + visible: showListCheck, + child: ToggleCheckListButton( + attribute: Attribute.unchecked, + controller: controller, + icon: Icons.check_box, + ), + ), + Visibility( + visible: showCodeBlock, + child: ToggleStyleButton( + attribute: Attribute.codeBlock, + controller: controller, + icon: Icons.code, + ), + ), + Visibility( + visible: !showListNumbers && + !showListBullets && + !showListCheck && + !showCodeBlock, + child: VerticalDivider( + indent: 12, endIndent: 12, color: Colors.grey.shade400)), + Visibility( + visible: showQuote, + child: ToggleStyleButton( + attribute: Attribute.blockQuote, + controller: controller, + icon: Icons.format_quote, + ), + ), + Visibility( + visible: showIndent, + child: IndentButton( + icon: Icons.format_indent_increase, + controller: controller, + isIncrease: true, + ), + ), + Visibility( + visible: showIndent, + child: IndentButton( + icon: Icons.format_indent_decrease, + controller: controller, + isIncrease: false, + ), + ), + Visibility( + visible: showQuote, + child: VerticalDivider( + indent: 12, endIndent: 12, color: Colors.grey.shade400)), + Visibility( + visible: showLink, + child: LinkStyleButton(controller: controller)), + Visibility( + visible: showHorizontalRule, + child: InsertEmbedButton( + controller: controller, + icon: Icons.horizontal_rule, + ), + ), + ]); + } + + final List children; + final double toolBarHeight; + + @override + _QuillToolbarState createState() => _QuillToolbarState(); + + @override + Size get preferredSize => Size.fromHeight(toolBarHeight); +} + +class _QuillToolbarState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + constraints: BoxConstraints.tightFor(height: widget.preferredSize.height), + color: Theme.of(context).canvasColor, + child: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widget.children, + ), + ), + ], + ), + ); + } +} + +class QuillIconButton extends StatelessWidget { + const QuillIconButton({ + required this.onPressed, + this.icon, + this.size = 40, + this.fillColor, + this.hoverElevation = 1, + this.highlightElevation = 1, + Key? key, + }) : super(key: key); + + final VoidCallback? onPressed; + final Widget? icon; + final double size; + final Color? fillColor; + final double hoverElevation; + final double highlightElevation; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(width: size, height: size), + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + fillColor: fillColor, + elevation: 0, + hoverElevation: hoverElevation, + highlightElevation: hoverElevation, + onPressed: onPressed, + child: icon, + ), + ); + } +} + +class QuillDropdownButton extends StatefulWidget { + const QuillDropdownButton({ + required this.child, + required this.initialValue, + required this.items, + required this.onSelected, + this.height = 40, + this.fillColor, + this.hoverElevation = 1, + this.highlightElevation = 1, + Key? key, + }) : super(key: key); + + final double height; + final Color? fillColor; + final double hoverElevation; + final double highlightElevation; + final Widget child; + final T initialValue; + final List> items; + final ValueChanged onSelected; + + @override + _QuillDropdownButtonState createState() => _QuillDropdownButtonState(); +} + +class _QuillDropdownButtonState extends State> { + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(height: widget.height), + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + fillColor: widget.fillColor, + elevation: 0, + hoverElevation: widget.hoverElevation, + highlightElevation: widget.hoverElevation, + onPressed: _showMenu, + child: _buildContent(context), + ), + ); + } + + void _showMenu() { + final popupMenuTheme = PopupMenuTheme.of(context); + final button = context.findRenderObject() as RenderBox; + final overlay = + Overlay.of(context)!.context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomLeft(Offset.zero), + ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + showMenu( + context: context, + elevation: 4, + // widget.elevation ?? popupMenuTheme.elevation, + initialValue: widget.initialValue, + items: widget.items, + position: position, + shape: popupMenuTheme.shape, + // widget.shape ?? popupMenuTheme.shape, + color: popupMenuTheme.color, // widget.color ?? popupMenuTheme.color, + // captureInheritedThemes: widget.captureInheritedThemes, + ).then((newValue) { + if (!mounted) return null; + if (newValue == null) { + // if (widget.onCanceled != null) widget.onCanceled(); + return null; + } + widget.onSelected(newValue); + }); + } + + Widget _buildContent(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 110), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + widget.child, + Expanded(child: Container()), + const Icon(Icons.arrow_drop_down, size: 15) + ], + ), + ), + ); + } +} diff --git a/lib/utils/color.dart b/lib/utils/color.dart index 93b6e12b..f126cf52 100644 --- a/lib/utils/color.dart +++ b/lib/utils/color.dart @@ -1,125 +1,3 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -Color stringToColor(String? s) { - switch (s) { - case 'transparent': - return Colors.transparent; - case 'black': - return Colors.black; - case 'black12': - return Colors.black12; - case 'black26': - return Colors.black26; - case 'black38': - return Colors.black38; - case 'black45': - return Colors.black45; - case 'black54': - return Colors.black54; - case 'black87': - return Colors.black87; - case 'white': - return Colors.white; - case 'white10': - return Colors.white10; - case 'white12': - return Colors.white12; - case 'white24': - return Colors.white24; - case 'white30': - return Colors.white30; - case 'white38': - return Colors.white38; - case 'white54': - return Colors.white54; - case 'white60': - return Colors.white60; - case 'white70': - return Colors.white70; - case 'red': - return Colors.red; - case 'redAccent': - return Colors.redAccent; - case 'amber': - return Colors.amber; - case 'amberAccent': - return Colors.amberAccent; - case 'yellow': - return Colors.yellow; - case 'yellowAccent': - return Colors.yellowAccent; - case 'teal': - return Colors.teal; - case 'tealAccent': - return Colors.tealAccent; - case 'purple': - return Colors.purple; - case 'purpleAccent': - return Colors.purpleAccent; - case 'pink': - return Colors.pink; - case 'pinkAccent': - return Colors.pinkAccent; - case 'orange': - return Colors.orange; - case 'orangeAccent': - return Colors.orangeAccent; - case 'deepOrange': - return Colors.deepOrange; - case 'deepOrangeAccent': - return Colors.deepOrangeAccent; - case 'indigo': - return Colors.indigo; - case 'indigoAccent': - return Colors.indigoAccent; - case 'lime': - return Colors.lime; - case 'limeAccent': - return Colors.limeAccent; - case 'grey': - return Colors.grey; - case 'blueGrey': - return Colors.blueGrey; - case 'green': - return Colors.green; - case 'greenAccent': - return Colors.greenAccent; - case 'lightGreen': - return Colors.lightGreen; - case 'lightGreenAccent': - return Colors.lightGreenAccent; - case 'blue': - return Colors.blue; - case 'blueAccent': - return Colors.blueAccent; - case 'lightBlue': - return Colors.lightBlue; - case 'lightBlueAccent': - return Colors.lightBlueAccent; - case 'cyan': - return Colors.cyan; - case 'cyanAccent': - return Colors.cyanAccent; - case 'brown': - return Colors.brown; - } - - if (s!.startsWith('rgba')) { - s = s.substring(5); // trim left 'rgba(' - s = s.substring(0, s.length - 1); // trim right ')' - final arr = s.split(',').map((e) => e.trim()).toList(); - return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]), - int.parse(arr[2]), double.parse(arr[3])); - } - - if (!s.startsWith('#')) { - throw 'Color code not supported'; - } - - var hex = s.replaceFirst('#', ''); - hex = hex.length == 6 ? 'ff$hex' : hex; - final val = int.parse(hex, radix: 16); - return Color(val); -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/utils/color.dart'; diff --git a/lib/utils/diff_delta.dart b/lib/utils/diff_delta.dart index 003bae47..08d30f51 100644 --- a/lib/utils/diff_delta.dart +++ b/lib/utils/diff_delta.dart @@ -1,102 +1,3 @@ -import 'dart:math' as math; - -import '../models/quill_delta.dart'; - -const Set WHITE_SPACE = { - 0x9, - 0xA, - 0xB, - 0xC, - 0xD, - 0x1C, - 0x1D, - 0x1E, - 0x1F, - 0x20, - 0xA0, - 0x1680, - 0x2000, - 0x2001, - 0x2002, - 0x2003, - 0x2004, - 0x2005, - 0x2006, - 0x2007, - 0x2008, - 0x2009, - 0x200A, - 0x202F, - 0x205F, - 0x3000 -}; - -// Diff between two texts - old text and new text -class Diff { - Diff(this.start, this.deleted, this.inserted); - - // Start index in old text at which changes begin. - final int start; - - /// The deleted text - final String deleted; - - // The inserted text - final String inserted; - - @override - String toString() { - return 'Diff[$start, "$deleted", "$inserted"]'; - } -} - -/* Get diff operation between old text and new text */ -Diff getDiff(String oldText, String newText, int cursorPosition) { - var end = oldText.length; - final delta = newText.length - end; - for (final limit = math.max(0, cursorPosition - delta); - end > limit && oldText[end - 1] == newText[end + delta - 1]; - end--) {} - var start = 0; - for (final startLimit = cursorPosition - math.max(0, delta); - start < startLimit && oldText[start] == newText[start]; - start++) {} - final deleted = (start >= end) ? '' : oldText.substring(start, end); - final inserted = newText.substring(start, end + delta); - return Diff(start, deleted, inserted); -} - -int getPositionDelta(Delta user, Delta actual) { - if (actual.isEmpty) { - return 0; - } - - final userItr = DeltaIterator(user); - final actualItr = DeltaIterator(actual); - var diff = 0; - while (userItr.hasNext || actualItr.hasNext) { - final length = math.min(userItr.peekLength(), actualItr.peekLength()); - final userOperation = userItr.next(length as int); - final actualOperation = actualItr.next(length); - if (userOperation.length != actualOperation.length) { - throw 'userOp ${userOperation.length} does not match actualOp ${actualOperation.length}'; - } - if (userOperation.key == actualOperation.key) { - continue; - } else if (userOperation.isInsert && actualOperation.isRetain) { - diff -= userOperation.length!; - } else if (userOperation.isDelete && actualOperation.isRetain) { - diff += userOperation.length!; - } else if (userOperation.isRetain && actualOperation.isInsert) { - String? operationTxt = ''; - if (actualOperation.data is String) { - operationTxt = actualOperation.data as String?; - } - if (operationTxt!.startsWith('\n')) { - continue; - } - diff += actualOperation.length!; - } - } - return diff; -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../../src/utils/diff_delta.dart'; diff --git a/lib/widgets/box.dart b/lib/widgets/box.dart index 75547923..d97c610a 100644 --- a/lib/widgets/box.dart +++ b/lib/widgets/box.dart @@ -1,39 +1,3 @@ -import 'package:flutter/rendering.dart'; - -import '../models/documents/nodes/container.dart'; - -abstract class RenderContentProxyBox implements RenderBox { - double getPreferredLineHeight(); - - Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype); - - TextPosition getPositionForOffset(Offset offset); - - double? getFullHeightForCaret(TextPosition position); - - TextRange getWordBoundary(TextPosition position); - - List getBoxesForSelection(TextSelection textSelection); -} - -abstract class RenderEditableBox extends RenderBox { - Container getContainer(); - - double preferredLineHeight(TextPosition position); - - Offset getOffsetForCaret(TextPosition position); - - TextPosition getPositionForOffset(Offset offset); - - TextPosition? getPositionAbove(TextPosition position); - - TextPosition? getPositionBelow(TextPosition position); - - TextRange getWordBoundary(TextPosition position); - - TextRange getLineBoundary(TextPosition position); - - TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection); - - TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection); -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/box.dart'; diff --git a/lib/widgets/controller.dart b/lib/widgets/controller.dart index 4820b3f9..e1177f78 100644 --- a/lib/widgets/controller.dart +++ b/lib/widgets/controller.dart @@ -1,228 +1,3 @@ -import 'dart:math' as math; - -import 'package:flutter/cupertino.dart'; -import 'package:tuple/tuple.dart'; - -import '../models/documents/attribute.dart'; -import '../models/documents/document.dart'; -import '../models/documents/nodes/embed.dart'; -import '../models/documents/style.dart'; -import '../models/quill_delta.dart'; -import '../utils/diff_delta.dart'; - -class QuillController extends ChangeNotifier { - QuillController( - {required this.document, - required this.selection, - this.iconSize = 18, - this.toolbarHeightFactor = 2}); - - factory QuillController.basic() { - return QuillController( - document: Document(), - selection: const TextSelection.collapsed(offset: 0), - ); - } - - final Document document; - TextSelection selection; - double iconSize; - double toolbarHeightFactor; - - Style toggledStyle = Style(); - bool ignoreFocusOnTextChange = false; - - /// Controls whether this [QuillController] instance has already been disposed - /// of - /// - /// This is a safe approach to make sure that listeners don't crash when - /// adding, removing or listeners to this instance. - bool _isDisposed = false; - - // item1: Document state before [change]. - // - // item2: Change delta applied to the document. - // - // item3: The source of this change. - Stream> get changes => document.changes; - - TextEditingValue get plainTextEditingValue => TextEditingValue( - text: document.toPlainText(), - selection: selection, - ); - - Style getSelectionStyle() { - return document - .collectStyle(selection.start, selection.end - selection.start) - .mergeAll(toggledStyle); - } - - void undo() { - final tup = document.undo(); - if (tup.item1) { - _handleHistoryChange(tup.item2); - } - } - - void _handleHistoryChange(int? len) { - if (len != 0) { - // if (this.selection.extentOffset >= document.length) { - // // cursor exceeds the length of document, position it in the end - // updateSelection( - // TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL); - updateSelection( - TextSelection.collapsed(offset: selection.baseOffset + len!), - ChangeSource.LOCAL); - } else { - // no need to move cursor - notifyListeners(); - } - } - - void redo() { - final tup = document.redo(); - if (tup.item1) { - _handleHistoryChange(tup.item2); - } - } - - bool get hasUndo => document.hasUndo; - - bool get hasRedo => document.hasRedo; - - void replaceText( - int index, int len, Object? data, TextSelection? textSelection, - {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, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); - var shouldRetainDelta = toggledStyle.isNotEmpty && - delta.isNotEmpty && - delta.length <= 2 && - delta.last.isInsert; - if (shouldRetainDelta && - toggledStyle.isNotEmpty && - delta.length == 2 && - delta.last.data == '\n') { - // if all attributes are inline, shouldRetainDelta should be false - final anyAttributeNotInline = - toggledStyle.values.any((attr) => !attr.isInline); - if (!anyAttributeNotInline) { - shouldRetainDelta = false; - } - } - if (shouldRetainDelta) { - final 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 || delta.isEmpty) { - _updateSelection(textSelection, ChangeSource.LOCAL); - } else { - final user = Delta() - ..retain(index) - ..insert(data) - ..delete(len); - final positionDelta = getPositionDelta(user, delta); - _updateSelection( - textSelection.copyWith( - baseOffset: textSelection.baseOffset + positionDelta, - extentOffset: textSelection.extentOffset + positionDelta, - ), - ChangeSource.LOCAL, - ); - } - } - - if (ignoreFocus) { - ignoreFocusOnTextChange = true; - } - notifyListeners(); - ignoreFocusOnTextChange = false; - } - - void formatText(int index, int len, Attribute? attribute) { - if (len == 0 && - attribute!.isInline && - attribute.key != Attribute.link.key) { - toggledStyle = toggledStyle.put(attribute); - } - - final change = document.format(index, len, attribute); - final adjustedSelection = selection.copyWith( - baseOffset: change.transformPosition(selection.baseOffset), - extentOffset: change.transformPosition(selection.extentOffset)); - if (selection != adjustedSelection) { - _updateSelection(adjustedSelection, ChangeSource.LOCAL); - } - notifyListeners(); - } - - void formatSelection(Attribute? attribute) { - formatText(selection.start, selection.end - selection.start, attribute); - } - - void updateSelection(TextSelection textSelection, ChangeSource source) { - _updateSelection(textSelection, source); - notifyListeners(); - } - - void compose(Delta delta, TextSelection textSelection, ChangeSource source) { - if (delta.isNotEmpty) { - document.compose(delta, source); - } - - 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 addListener(VoidCallback listener) { - // By using `_isDisposed`, make sure that `addListener` won't be called on a - // disposed `ChangeListener` - if (!_isDisposed) { - super.addListener(listener); - } - } - - @override - void removeListener(VoidCallback listener) { - // By using `_isDisposed`, make sure that `removeListener` won't be called - // on a disposed `ChangeListener` - if (!_isDisposed) { - super.removeListener(listener); - } - } - - @override - void dispose() { - if (!_isDisposed) { - document.close(); - } - - _isDisposed = true; - super.dispose(); - } - - void _updateSelection(TextSelection textSelection, ChangeSource source) { - selection = textSelection; - final end = document.length - 1; - selection = selection.copyWith( - baseOffset: math.min(selection.baseOffset, end), - extentOffset: math.min(selection.extentOffset, end)); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/controller.dart'; diff --git a/lib/widgets/cursor.dart b/lib/widgets/cursor.dart index 383906d0..3528ad16 100644 --- a/lib/widgets/cursor.dart +++ b/lib/widgets/cursor.dart @@ -1,231 +1,3 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; - -import 'box.dart'; - -const Duration _FADE_DURATION = Duration(milliseconds: 250); - -class CursorStyle { - const CursorStyle({ - required this.color, - required this.backgroundColor, - this.width = 1.0, - this.height, - this.radius, - this.offset, - this.opacityAnimates = false, - this.paintAboveText = false, - }); - - final Color color; - final Color backgroundColor; - final double width; - final double? height; - final Radius? radius; - final Offset? offset; - final bool opacityAnimates; - final bool paintAboveText; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CursorStyle && - runtimeType == other.runtimeType && - color == other.color && - backgroundColor == other.backgroundColor && - width == other.width && - height == other.height && - radius == other.radius && - offset == other.offset && - opacityAnimates == other.opacityAnimates && - paintAboveText == other.paintAboveText; - - @override - int get hashCode => - color.hashCode ^ - backgroundColor.hashCode ^ - width.hashCode ^ - height.hashCode ^ - radius.hashCode ^ - offset.hashCode ^ - opacityAnimates.hashCode ^ - paintAboveText.hashCode; -} - -class CursorCont extends ChangeNotifier { - CursorCont({ - required this.show, - required CursorStyle style, - required TickerProvider tickerProvider, - }) : _style = style, - _blink = ValueNotifier(false), - color = ValueNotifier(style.color) { - _blinkOpacityCont = - AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); - _blinkOpacityCont.addListener(_onColorTick); - } - - final ValueNotifier show; - final ValueNotifier _blink; - final ValueNotifier color; - late AnimationController _blinkOpacityCont; - Timer? _cursorTimer; - bool _targetCursorVisibility = false; - CursorStyle _style; - - ValueNotifier get cursorBlink => _blink; - - ValueNotifier get cursorColor => color; - - CursorStyle get style => _style; - - set style(CursorStyle value) { - if (_style == value) return; - _style = value; - notifyListeners(); - } - - @override - void dispose() { - _blinkOpacityCont.removeListener(_onColorTick); - stopCursorTimer(); - _blinkOpacityCont.dispose(); - assert(_cursorTimer == null); - super.dispose(); - } - - void _cursorTick(Timer timer) { - _targetCursorVisibility = !_targetCursorVisibility; - final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; - if (style.opacityAnimates) { - _blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); - } else { - _blinkOpacityCont.value = targetOpacity; - } - } - - void _cursorWaitForStart(Timer timer) { - _cursorTimer?.cancel(); - _cursorTimer = - Timer.periodic(const Duration(milliseconds: 500), _cursorTick); - } - - void startCursorTimer() { - _targetCursorVisibility = true; - _blinkOpacityCont.value = 1.0; - - if (style.opacityAnimates) { - _cursorTimer = Timer.periodic( - const Duration(milliseconds: 150), _cursorWaitForStart); - } else { - _cursorTimer = - Timer.periodic(const Duration(milliseconds: 500), _cursorTick); - } - } - - void stopCursorTimer({bool resetCharTicks = true}) { - _cursorTimer?.cancel(); - _cursorTimer = null; - _targetCursorVisibility = false; - _blinkOpacityCont.value = 0.0; - - if (style.opacityAnimates) { - _blinkOpacityCont - ..stop() - ..value = 0.0; - } - } - - void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) { - if (show.value && - _cursorTimer == null && - hasFocus && - selection.isCollapsed) { - startCursorTimer(); - } else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) { - stopCursorTimer(); - } - } - - void _onColorTick() { - color.value = _style.color.withOpacity(_blinkOpacityCont.value); - _blink.value = show.value && _blinkOpacityCont.value > 0; - } -} - -class CursorPainter { - CursorPainter(this.editable, this.style, this.prototype, this.color, - this.devicePixelRatio); - - final RenderContentProxyBox? editable; - final CursorStyle style; - final Rect? prototype; - final Color color; - final double devicePixelRatio; - - void paint(Canvas canvas, Offset offset, TextPosition position) { - assert(prototype != null); - - final caretOffset = - editable!.getOffsetForCaret(position, prototype) + offset; - var caretRect = prototype!.shift(caretOffset); - if (style.offset != null) { - caretRect = caretRect.shift(style.offset!); - } - - if (caretRect.left < 0.0) { - caretRect = caretRect.shift(Offset(-caretRect.left, 0)); - } - - final caretHeight = editable!.getFullHeightForCaret(position); - if (caretHeight != null) { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - caretRect = Rect.fromLTWH( - caretRect.left, - caretRect.top - 2.0, - caretRect.width, - caretHeight, - ); - break; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - caretRect = Rect.fromLTWH( - caretRect.left, - caretRect.top + (caretHeight - caretRect.height) / 2, - caretRect.width, - caretRect.height, - ); - break; - default: - throw UnimplementedError(); - } - } - - final caretPosition = editable!.localToGlobal(caretRect.topLeft); - final pixelMultiple = 1.0 / devicePixelRatio; - caretRect = caretRect.shift(Offset( - caretPosition.dx.isFinite - ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - - caretPosition.dx - : caretPosition.dx, - caretPosition.dy.isFinite - ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - - caretPosition.dy - : caretPosition.dy)); - - final paint = Paint()..color = color; - if (style.radius == null) { - canvas.drawRect(caretRect, paint); - return; - } - - final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); - canvas.drawRRect(caretRRect, paint); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/cursor.dart'; diff --git a/lib/widgets/default_styles.dart b/lib/widgets/default_styles.dart index 1cebe135..3fffda6f 100644 --- a/lib/widgets/default_styles.dart +++ b/lib/widgets/default_styles.dart @@ -1,223 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:tuple/tuple.dart'; - -class QuillStyles extends InheritedWidget { - const QuillStyles({ - required this.data, - required Widget child, - Key? key, - }) : super(key: key, child: child); - - final DefaultStyles data; - - @override - bool updateShouldNotify(QuillStyles oldWidget) { - return data != oldWidget.data; - } - - static DefaultStyles? getStyles(BuildContext context, bool nullOk) { - final widget = context.dependOnInheritedWidgetOfExactType(); - if (widget == null && nullOk) { - return null; - } - assert(widget != null); - return widget!.data; - } -} - -class DefaultTextBlockStyle { - DefaultTextBlockStyle( - this.style, - this.verticalSpacing, - this.lineSpacing, - this.decoration, - ); - - final TextStyle style; - - final Tuple2 verticalSpacing; - - final Tuple2 lineSpacing; - - final BoxDecoration? decoration; -} - -class DefaultStyles { - DefaultStyles({ - this.h1, - this.h2, - this.h3, - this.paragraph, - this.bold, - this.italic, - this.underline, - this.strikeThrough, - this.link, - this.color, - this.placeHolder, - this.lists, - this.quote, - this.code, - this.indent, - this.align, - this.leading, - this.sizeSmall, - this.sizeLarge, - this.sizeHuge, - }); - - final DefaultTextBlockStyle? h1; - final DefaultTextBlockStyle? h2; - final DefaultTextBlockStyle? h3; - final DefaultTextBlockStyle? paragraph; - final TextStyle? bold; - final TextStyle? italic; - final TextStyle? underline; - final TextStyle? strikeThrough; - final TextStyle? sizeSmall; // 'small' - final TextStyle? sizeLarge; // 'large' - final TextStyle? sizeHuge; // 'huge' - final TextStyle? link; - final Color? color; - final DefaultTextBlockStyle? placeHolder; - final DefaultTextBlockStyle? lists; - final DefaultTextBlockStyle? quote; - final DefaultTextBlockStyle? code; - final DefaultTextBlockStyle? indent; - final DefaultTextBlockStyle? align; - final DefaultTextBlockStyle? leading; - - static DefaultStyles getInstance(BuildContext context) { - final themeData = Theme.of(context); - final defaultTextStyle = DefaultTextStyle.of(context); - final baseStyle = defaultTextStyle.style.copyWith( - fontSize: 16, - height: 1.3, - ); - const baseSpacing = Tuple2(6, 0); - String fontFamily; - switch (themeData.platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - fontFamily = 'Menlo'; - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.windows: - case TargetPlatform.linux: - fontFamily = 'Roboto Mono'; - break; - default: - throw UnimplementedError(); - } - - return DefaultStyles( - h1: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 34, - color: defaultTextStyle.style.color!.withOpacity(0.70), - height: 1.15, - fontWeight: FontWeight.w300, - ), - const Tuple2(16, 0), - const Tuple2(0, 0), - null), - h2: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 24, - color: defaultTextStyle.style.color!.withOpacity(0.70), - height: 1.15, - fontWeight: FontWeight.normal, - ), - const Tuple2(8, 0), - const Tuple2(0, 0), - null), - h3: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 20, - color: defaultTextStyle.style.color!.withOpacity(0.70), - height: 1.25, - fontWeight: FontWeight.w500, - ), - const Tuple2(8, 0), - const Tuple2(0, 0), - null), - paragraph: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), - bold: const TextStyle(fontWeight: FontWeight.bold), - italic: const TextStyle(fontStyle: FontStyle.italic), - underline: const TextStyle(decoration: TextDecoration.underline), - strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough), - link: TextStyle( - color: themeData.accentColor, - decoration: TextDecoration.underline, - ), - placeHolder: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 20, - height: 1.5, - color: Colors.grey.withOpacity(0.6), - ), - const Tuple2(0, 0), - const Tuple2(0, 0), - null), - lists: DefaultTextBlockStyle( - baseStyle, baseSpacing, const Tuple2(0, 6), null), - quote: DefaultTextBlockStyle( - TextStyle(color: baseStyle.color!.withOpacity(0.6)), - baseSpacing, - const Tuple2(6, 2), - BoxDecoration( - border: Border( - left: BorderSide(width: 4, color: Colors.grey.shade300), - ), - )), - code: DefaultTextBlockStyle( - TextStyle( - color: Colors.blue.shade900.withOpacity(0.9), - fontFamily: fontFamily, - fontSize: 13, - height: 1.15, - ), - baseSpacing, - const Tuple2(0, 0), - BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(2), - )), - indent: DefaultTextBlockStyle( - baseStyle, baseSpacing, const Tuple2(0, 6), null), - align: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), - leading: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), - sizeSmall: const TextStyle(fontSize: 10), - sizeLarge: const TextStyle(fontSize: 18), - sizeHuge: const TextStyle(fontSize: 22)); - } - - DefaultStyles merge(DefaultStyles other) { - return DefaultStyles( - h1: other.h1 ?? h1, - h2: other.h2 ?? h2, - h3: other.h3 ?? h3, - paragraph: other.paragraph ?? paragraph, - bold: other.bold ?? bold, - italic: other.italic ?? italic, - underline: other.underline ?? underline, - strikeThrough: other.strikeThrough ?? strikeThrough, - link: other.link ?? link, - color: other.color ?? color, - placeHolder: other.placeHolder ?? placeHolder, - lists: other.lists ?? lists, - quote: other.quote ?? quote, - code: other.code ?? code, - indent: other.indent ?? indent, - align: other.align ?? align, - leading: other.leading ?? leading, - sizeSmall: other.sizeSmall ?? sizeSmall, - sizeLarge: other.sizeLarge ?? sizeLarge, - sizeHuge: other.sizeHuge ?? sizeHuge); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/default_styles.dart'; diff --git a/lib/widgets/delegate.dart b/lib/widgets/delegate.dart index 4b4bdea7..c1db553e 100644 --- a/lib/widgets/delegate.dart +++ b/lib/widgets/delegate.dart @@ -1,148 +1,3 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -import '../models/documents/nodes/leaf.dart'; -import 'editor.dart'; -import 'text_selection.dart'; - -typedef EmbedBuilder = Widget Function(BuildContext context, Embed node); - -abstract class EditorTextSelectionGestureDetectorBuilderDelegate { - GlobalKey getEditableTextKey(); - - bool getForcePressEnabled(); - - bool getSelectionEnabled(); -} - -class EditorTextSelectionGestureDetectorBuilder { - EditorTextSelectionGestureDetectorBuilder(this.delegate); - - final EditorTextSelectionGestureDetectorBuilderDelegate delegate; - bool shouldShowSelectionToolbar = true; - - EditorState? getEditor() { - return delegate.getEditableTextKey().currentState; - } - - RenderEditor? getRenderEditor() { - return getEditor()!.getRenderEditor(); - } - - void onTapDown(TapDownDetails details) { - getRenderEditor()!.handleTapDown(details); - - final kind = details.kind; - shouldShowSelectionToolbar = kind == null || - kind == PointerDeviceKind.touch || - kind == PointerDeviceKind.stylus; - } - - void onForcePressStart(ForcePressDetails details) { - assert(delegate.getForcePressEnabled()); - shouldShowSelectionToolbar = true; - if (delegate.getSelectionEnabled()) { - getRenderEditor()!.selectWordsInRange( - details.globalPosition, - null, - SelectionChangedCause.forcePress, - ); - } - } - - void onForcePressEnd(ForcePressDetails details) { - assert(delegate.getForcePressEnabled()); - getRenderEditor()!.selectWordsInRange( - details.globalPosition, - null, - SelectionChangedCause.forcePress, - ); - if (shouldShowSelectionToolbar) { - getEditor()!.showToolbar(); - } - } - - void onSingleTapUp(TapUpDetails details) { - if (delegate.getSelectionEnabled()) { - getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); - } - } - - void onSingleTapCancel() {} - - void onSingleLongTapStart(LongPressStartDetails details) { - if (delegate.getSelectionEnabled()) { - getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, - ); - } - } - - void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { - if (delegate.getSelectionEnabled()) { - getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, - ); - } - } - - void onSingleLongTapEnd(LongPressEndDetails details) { - if (shouldShowSelectionToolbar) { - getEditor()!.showToolbar(); - } - } - - void onDoubleTapDown(TapDownDetails details) { - if (delegate.getSelectionEnabled()) { - getRenderEditor()!.selectWord(SelectionChangedCause.tap); - if (shouldShowSelectionToolbar) { - getEditor()!.showToolbar(); - } - } - } - - void onDragSelectionStart(DragStartDetails details) { - getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.drag, - ); - } - - void onDragSelectionUpdate( - DragStartDetails startDetails, DragUpdateDetails updateDetails) { - getRenderEditor()!.selectPositionAt( - startDetails.globalPosition, - updateDetails.globalPosition, - SelectionChangedCause.drag, - ); - } - - void onDragSelectionEnd(DragEndDetails details) {} - - Widget build(HitTestBehavior behavior, Widget child) { - return EditorTextSelectionGestureDetector( - onTapDown: onTapDown, - onForcePressStart: - delegate.getForcePressEnabled() ? onForcePressStart : null, - onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null, - onSingleTapUp: onSingleTapUp, - onSingleTapCancel: onSingleTapCancel, - onSingleLongTapStart: onSingleLongTapStart, - onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, - onSingleLongTapEnd: onSingleLongTapEnd, - onDoubleTapDown: onDoubleTapDown, - onDragSelectionStart: onDragSelectionStart, - onDragSelectionUpdate: onDragSelectionUpdate, - onDragSelectionEnd: onDragSelectionEnd, - behavior: behavior, - child: child, - ); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/delegate.dart'; diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index 662a018c..c0d754f9 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -1,1145 +1,3 @@ -import 'dart:convert'; -import 'dart:io' as io; -import 'dart:math' as math; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.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:url_launcher/url_launcher.dart'; - -import '../models/documents/attribute.dart'; -import '../models/documents/document.dart'; -import '../models/documents/nodes/container.dart' as container_node; -import '../models/documents/nodes/embed.dart'; -import '../models/documents/nodes/leaf.dart' as leaf; -import '../models/documents/nodes/line.dart'; -import 'box.dart'; -import 'controller.dart'; -import 'cursor.dart'; -import 'default_styles.dart'; -import 'delegate.dart'; -import 'image.dart'; -import 'raw_editor.dart'; -import 'text_selection.dart'; - -const linkPrefixes = [ - 'mailto:', // email - 'tel:', // telephone - 'sms:', // SMS - 'callto:', - 'wtai:', - 'market:', - 'geopoint:', - 'ymsgr:', - 'msnim:', - 'gtalk:', // Google Talk - 'skype:', - 'sip:', // Lync - 'whatsapp:', - 'http' -]; - -abstract class EditorState extends State { - TextEditingValue getTextEditingValue(); - - void setTextEditingValue(TextEditingValue value); - - RenderEditor? getRenderEditor(); - - EditorTextSelectionOverlay? getSelectionOverlay(); - - bool showToolbar(); - - void hideToolbar(); - - void requestKeyboard(); -} - -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); -} - -String _standardizeImageUrl(String url) { - if (url.contains('base64')) { - return url.split(',')[1]; - } - return url; -} - -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.'); - } -} - -class QuillEditor extends StatefulWidget { - const QuillEditor( - {required this.controller, - required this.focusNode, - required this.scrollController, - required this.scrollable, - required this.padding, - required this.autoFocus, - required this.readOnly, - required this.expands, - this.showCursor, - this.placeholder, - this.enableInteractiveSelection = true, - this.scrollBottomInset = 0, - this.minHeight, - this.maxHeight, - this.customStyles, - this.textCapitalization = TextCapitalization.sentences, - this.keyboardAppearance = Brightness.light, - this.scrollPhysics, - this.onLaunchUrl, - this.onTapDown, - this.onTapUp, - this.onSingleLongTapStart, - this.onSingleLongTapMoveUpdate, - this.onSingleLongTapEnd, - this.embedBuilder = _defaultEmbedBuilder}); - - factory QuillEditor.basic({ - required QuillController controller, - required bool readOnly, - }) { - return QuillEditor( - controller: controller, - scrollController: ScrollController(), - scrollable: true, - focusNode: FocusNode(), - autoFocus: true, - readOnly: readOnly, - expands: false, - padding: EdgeInsets.zero); - } - - final QuillController controller; - final FocusNode focusNode; - final ScrollController scrollController; - final bool scrollable; - final double scrollBottomInset; - final EdgeInsetsGeometry padding; - final bool autoFocus; - final bool? showCursor; - final bool readOnly; - final String? placeholder; - final bool enableInteractiveSelection; - final double? minHeight; - final double? maxHeight; - final DefaultStyles? customStyles; - final bool expands; - final TextCapitalization textCapitalization; - final Brightness keyboardAppearance; - final ScrollPhysics? scrollPhysics; - final ValueChanged? onLaunchUrl; - // Returns whether gesture is handled - final bool Function( - TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; - - // Returns whether gesture is handled - final bool Function( - TapUpDetails details, TextPosition Function(Offset offset))? onTapUp; - - // Returns whether gesture is handled - final bool Function( - LongPressStartDetails details, TextPosition Function(Offset offset))? - onSingleLongTapStart; - - // Returns whether gesture is handled - final bool Function(LongPressMoveUpdateDetails details, - TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate; - // Returns whether gesture is handled - final bool Function( - LongPressEndDetails details, TextPosition Function(Offset offset))? - onSingleLongTapEnd; - - final EmbedBuilder embedBuilder; - - @override - _QuillEditorState createState() => _QuillEditorState(); -} - -class _QuillEditorState extends State - implements EditorTextSelectionGestureDetectorBuilderDelegate { - final GlobalKey _editorKey = GlobalKey(); - late EditorTextSelectionGestureDetectorBuilder - _selectionGestureDetectorBuilder; - - @override - void initState() { - super.initState(); - _selectionGestureDetectorBuilder = - _QuillEditorSelectionGestureDetectorBuilder(this); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final selectionTheme = TextSelectionTheme.of(context); - - TextSelectionControls textSelectionControls; - bool paintCursorAboveText; - bool cursorOpacityAnimates; - Offset? cursorOffset; - Color? cursorColor; - Color selectionColor; - Radius? cursorRadius; - - switch (theme.platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - textSelectionControls = materialTextSelectionControls; - paintCursorAboveText = false; - cursorOpacityAnimates = false; - cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; - selectionColor = selectionTheme.selectionColor ?? - theme.colorScheme.primary.withOpacity(0.40); - break; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - final cupertinoTheme = CupertinoTheme.of(context); - textSelectionControls = cupertinoTextSelectionControls; - paintCursorAboveText = true; - cursorOpacityAnimates = true; - cursorColor ??= - selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; - selectionColor = selectionTheme.selectionColor ?? - cupertinoTheme.primaryColor.withOpacity(0.40); - cursorRadius ??= const Radius.circular(2); - cursorOffset = Offset( - iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); - break; - default: - throw UnimplementedError(); - } - - return _selectionGestureDetectorBuilder.build( - HitTestBehavior.translucent, - RawEditor( - _editorKey, - widget.controller, - widget.focusNode, - widget.scrollController, - widget.scrollable, - widget.scrollBottomInset, - widget.padding, - widget.readOnly, - widget.placeholder, - widget.onLaunchUrl, - ToolbarOptions( - copy: widget.enableInteractiveSelection, - cut: widget.enableInteractiveSelection, - paste: widget.enableInteractiveSelection, - selectAll: widget.enableInteractiveSelection, - ), - theme.platform == TargetPlatform.iOS || - theme.platform == TargetPlatform.android, - widget.showCursor, - CursorStyle( - color: cursorColor, - backgroundColor: Colors.grey, - width: 2, - radius: cursorRadius, - offset: cursorOffset, - paintAboveText: paintCursorAboveText, - opacityAnimates: cursorOpacityAnimates, - ), - widget.textCapitalization, - widget.maxHeight, - widget.minHeight, - widget.customStyles, - widget.expands, - widget.autoFocus, - selectionColor, - textSelectionControls, - widget.keyboardAppearance, - widget.enableInteractiveSelection, - widget.scrollPhysics, - widget.embedBuilder), - ); - } - - @override - GlobalKey getEditableTextKey() { - return _editorKey; - } - - @override - bool getForcePressEnabled() { - return false; - } - - @override - bool getSelectionEnabled() { - return widget.enableInteractiveSelection; - } - - void _requestKeyboard() { - _editorKey.currentState!.requestKeyboard(); - } -} - -class _QuillEditorSelectionGestureDetectorBuilder - extends EditorTextSelectionGestureDetectorBuilder { - _QuillEditorSelectionGestureDetectorBuilder(this._state) : super(_state); - - final _QuillEditorState _state; - - @override - void onForcePressStart(ForcePressDetails details) { - super.onForcePressStart(details); - if (delegate.getSelectionEnabled() && shouldShowSelectionToolbar) { - getEditor()!.showToolbar(); - } - } - - @override - void onForcePressEnd(ForcePressDetails details) {} - - @override - void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { - if (_state.widget.onSingleLongTapMoveUpdate != null) { - final renderEditor = getRenderEditor(); - if (renderEditor != null) { - if (_state.widget.onSingleLongTapMoveUpdate!( - details, renderEditor.getPositionForOffset)) { - return; - } - } - } - if (!delegate.getSelectionEnabled()) { - return; - } - switch (Theme.of(_state.context).platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, - ); - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - getRenderEditor()!.selectWordsInRange( - details.globalPosition - details.offsetFromOrigin, - details.globalPosition, - SelectionChangedCause.longPress, - ); - break; - default: - throw 'Invalid platform'; - } - } - - bool _onTapping(TapUpDetails details) { - if (_state.widget.controller.document.isEmpty()) { - return false; - } - final pos = getRenderEditor()!.getPositionForOffset(details.globalPosition); - final result = - getEditor()!.widget.controller.document.queryChild(pos.offset); - if (result.node == null) { - return false; - } - final line = result.node as Line; - final segmentResult = line.queryChild(result.offset, false); - if (segmentResult.node == null) { - if (line.length == 1) { - getEditor()!.widget.controller.updateSelection( - TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); - return true; - } - return false; - } - final segment = segmentResult.node as leaf.Leaf; - if (segment.style.containsKey(Attribute.link.key)) { - var launchUrl = getEditor()!.widget.onLaunchUrl; - launchUrl ??= _launchUrl; - String? link = segment.style.attributes[Attribute.link.key]!.value; - if (getEditor()!.widget.readOnly && link != null) { - link = link.trim(); - if (!linkPrefixes - .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { - link = 'https://$link'; - } - launchUrl(link); - } - return false; - } - if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) { - final blockEmbed = segment.value as BlockEmbed; - if (blockEmbed.type == 'image') { - final imageUrl = _standardizeImageUrl(blockEmbed.data); - Navigator.push( - getEditor()!.context, - MaterialPageRoute( - builder: (context) => ImageTapWrapper( - imageProvider: imageUrl.startsWith('http') - ? NetworkImage(imageUrl) - : isBase64(imageUrl) - ? Image.memory(base64.decode(imageUrl)) - as ImageProvider? - : FileImage(io.File(imageUrl)), - ), - ), - ); - } - } - - return false; - } - - Future _launchUrl(String url) async { - 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)) { - 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)) { - return; - } - } - } - - getEditor()!.hideToolbar(); - - final positionSelected = _onTapping(details); - - if (delegate.getSelectionEnabled() && !positionSelected) { - switch (Theme.of(_state.context).platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - switch (details.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - getRenderEditor()!.selectPosition(SelectionChangedCause.tap); - break; - case PointerDeviceKind.touch: - case PointerDeviceKind.unknown: - getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); - break; - } - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - getRenderEditor()!.selectPosition(SelectionChangedCause.tap); - break; - } - } - _state._requestKeyboard(); - } - - @override - void onSingleLongTapStart(LongPressStartDetails details) { - if (_state.widget.onSingleLongTapStart != null) { - final renderEditor = getRenderEditor(); - if (renderEditor != null) { - if (_state.widget.onSingleLongTapStart!( - details, renderEditor.getPositionForOffset)) { - return; - } - } - } - - if (delegate.getSelectionEnabled()) { - switch (Theme.of(_state.context).platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, - ); - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - getRenderEditor()!.selectWord(SelectionChangedCause.longPress); - Feedback.forLongPress(_state.context); - break; - default: - throw 'Invalid platform'; - } - } - } - - @override - void onSingleLongTapEnd(LongPressEndDetails details) { - if (_state.widget.onSingleLongTapEnd != null) { - final renderEditor = getRenderEditor(); - if (renderEditor != null) { - if (_state.widget.onSingleLongTapEnd!( - details, renderEditor.getPositionForOffset)) { - return; - } - } - } - super.onSingleLongTapEnd(details); - } -} - -typedef TextSelectionChangedHandler = void Function( - TextSelection selection, SelectionChangedCause cause); - -class RenderEditor extends RenderEditableContainerBox - implements RenderAbstractEditor { - RenderEditor( - List? children, - TextDirection textDirection, - double scrollBottomInset, - EdgeInsetsGeometry padding, - this.document, - this.selection, - this._hasFocus, - this.onSelectionChanged, - this._startHandleLayerLink, - this._endHandleLayerLink, - EdgeInsets floatingCursorAddedMargin, - ) : super( - children, - document.root, - textDirection, - scrollBottomInset, - padding, - ); - - Document document; - TextSelection selection; - bool _hasFocus = false; - LayerLink _startHandleLayerLink; - LayerLink _endHandleLayerLink; - TextSelectionChangedHandler onSelectionChanged; - final ValueNotifier _selectionStartInViewport = - ValueNotifier(true); - - ValueListenable get selectionStartInViewport => - _selectionStartInViewport; - - ValueListenable get selectionEndInViewport => _selectionEndInViewport; - final ValueNotifier _selectionEndInViewport = ValueNotifier(true); - - void setDocument(Document doc) { - if (document == doc) { - return; - } - document = doc; - markNeedsLayout(); - } - - void setHasFocus(bool h) { - if (_hasFocus == h) { - return; - } - _hasFocus = h; - markNeedsSemanticsUpdate(); - } - - void setSelection(TextSelection t) { - if (selection == t) { - return; - } - selection = t; - markNeedsPaint(); - } - - void setStartHandleLayerLink(LayerLink value) { - if (_startHandleLayerLink == value) { - return; - } - _startHandleLayerLink = value; - markNeedsPaint(); - } - - void setEndHandleLayerLink(LayerLink value) { - if (_endHandleLayerLink == value) { - return; - } - _endHandleLayerLink = value; - markNeedsPaint(); - } - - void setScrollBottomInset(double value) { - if (scrollBottomInset == value) { - return; - } - scrollBottomInset = value; - markNeedsPaint(); - } - - @override - List getEndpointsForSelection( - TextSelection textSelection) { - if (textSelection.isCollapsed) { - final child = childAtPosition(textSelection.extent); - final localPosition = TextPosition( - offset: textSelection.extentOffset - child.getContainer().offset); - final localOffset = child.getOffsetForCaret(localPosition); - final parentData = child.parentData as BoxParentData; - return [ - TextSelectionPoint( - Offset(0, child.preferredLineHeight(localPosition)) + - localOffset + - parentData.offset, - null) - ]; - } - - final baseNode = _container.queryChild(textSelection.start, false).node; - - var baseChild = firstChild; - while (baseChild != null) { - if (baseChild.getContainer() == baseNode) { - break; - } - baseChild = childAfter(baseChild); - } - assert(baseChild != null); - - final baseParentData = baseChild!.parentData as BoxParentData; - final baseSelection = - localSelection(baseChild.getContainer(), textSelection, true); - var basePoint = baseChild.getBaseEndpointForSelection(baseSelection); - basePoint = TextSelectionPoint( - basePoint.point + baseParentData.offset, basePoint.direction); - - final extentNode = _container.queryChild(textSelection.end, false).node; - RenderEditableBox? extentChild = baseChild; - while (extentChild != null) { - if (extentChild.getContainer() == extentNode) { - break; - } - extentChild = childAfter(extentChild); - } - assert(extentChild != null); - - final extentParentData = extentChild!.parentData as BoxParentData; - final extentSelection = - localSelection(extentChild.getContainer(), textSelection, true); - var extentPoint = - extentChild.getExtentEndpointForSelection(extentSelection); - extentPoint = TextSelectionPoint( - extentPoint.point + extentParentData.offset, extentPoint.direction); - - return [basePoint, extentPoint]; - } - - Offset? _lastTapDownPosition; - - @override - void handleTapDown(TapDownDetails details) { - _lastTapDownPosition = details.globalPosition; - } - - @override - void selectWordsInRange( - Offset from, - Offset? to, - SelectionChangedCause cause, - ) { - final firstPosition = getPositionForOffset(from); - final firstWord = selectWordAtPosition(firstPosition); - final lastWord = - to == null ? firstWord : selectWordAtPosition(getPositionForOffset(to)); - - _handleSelectionChange( - TextSelection( - baseOffset: firstWord.base.offset, - extentOffset: lastWord.extent.offset, - affinity: firstWord.affinity, - ), - cause, - ); - } - - void _handleSelectionChange( - TextSelection nextSelection, - SelectionChangedCause cause, - ) { - final focusingEmpty = nextSelection.baseOffset == 0 && - nextSelection.extentOffset == 0 && - !_hasFocus; - if (nextSelection == selection && - cause != SelectionChangedCause.keyboard && - !focusingEmpty) { - return; - } - onSelectionChanged(nextSelection, cause); - } - - @override - void selectWordEdge(SelectionChangedCause cause) { - assert(_lastTapDownPosition != null); - final position = getPositionForOffset(_lastTapDownPosition!); - final child = childAtPosition(position); - final nodeOffset = child.getContainer().offset; - final localPosition = TextPosition( - offset: position.offset - nodeOffset, - affinity: position.affinity, - ); - final localWord = child.getWordBoundary(localPosition); - final word = TextRange( - start: localWord.start + nodeOffset, - end: localWord.end + nodeOffset, - ); - if (position.offset - word.start <= 1) { - _handleSelectionChange( - TextSelection.collapsed(offset: word.start), - cause, - ); - } else { - _handleSelectionChange( - TextSelection.collapsed( - offset: word.end, affinity: TextAffinity.upstream), - cause, - ); - } - } - - @override - void selectPositionAt( - Offset from, - Offset? to, - SelectionChangedCause cause, - ) { - final fromPosition = getPositionForOffset(from); - final toPosition = to == null ? null : getPositionForOffset(to); - - var baseOffset = fromPosition.offset; - var extentOffset = fromPosition.offset; - if (toPosition != null) { - baseOffset = math.min(fromPosition.offset, toPosition.offset); - extentOffset = math.max(fromPosition.offset, toPosition.offset); - } - - final newSelection = TextSelection( - baseOffset: baseOffset, - extentOffset: extentOffset, - affinity: fromPosition.affinity, - ); - _handleSelectionChange(newSelection, cause); - } - - @override - void selectWord(SelectionChangedCause cause) { - selectWordsInRange(_lastTapDownPosition!, null, cause); - } - - @override - void selectPosition(SelectionChangedCause cause) { - selectPositionAt(_lastTapDownPosition!, null, cause); - } - - @override - TextSelection selectWordAtPosition(TextPosition position) { - final child = childAtPosition(position); - final nodeOffset = child.getContainer().offset; - final localPosition = TextPosition( - offset: position.offset - nodeOffset, affinity: position.affinity); - final localWord = child.getWordBoundary(localPosition); - final word = TextRange( - start: localWord.start + nodeOffset, - end: localWord.end + nodeOffset, - ); - if (position.offset >= word.end) { - return TextSelection.fromPosition(position); - } - return TextSelection(baseOffset: word.start, extentOffset: word.end); - } - - @override - TextSelection selectLineAtPosition(TextPosition position) { - final child = childAtPosition(position); - final nodeOffset = child.getContainer().offset; - final localPosition = TextPosition( - offset: position.offset - nodeOffset, affinity: position.affinity); - final localLineRange = child.getLineBoundary(localPosition); - final line = TextRange( - start: localLineRange.start + nodeOffset, - end: localLineRange.end + nodeOffset, - ); - - if (position.offset >= line.end) { - return TextSelection.fromPosition(position); - } - return TextSelection(baseOffset: line.start, extentOffset: line.end); - } - - @override - void paint(PaintingContext context, Offset offset) { - defaultPaint(context, offset); - _paintHandleLayers(context, getEndpointsForSelection(selection)); - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } - - void _paintHandleLayers( - PaintingContext context, List endpoints) { - var startPoint = endpoints[0].point; - startPoint = Offset( - startPoint.dx.clamp(0.0, size.width), - startPoint.dy.clamp(0.0, size.height), - ); - context.pushLayer( - LeaderLayer(link: _startHandleLayerLink, offset: startPoint), - super.paint, - Offset.zero, - ); - if (endpoints.length == 2) { - var endPoint = endpoints[1].point; - endPoint = Offset( - endPoint.dx.clamp(0.0, size.width), - endPoint.dy.clamp(0.0, size.height), - ); - context.pushLayer( - LeaderLayer(link: _endHandleLayerLink, offset: endPoint), - super.paint, - Offset.zero, - ); - } - } - - @override - double preferredLineHeight(TextPosition position) { - final child = childAtPosition(position); - return child.preferredLineHeight( - TextPosition(offset: position.offset - child.getContainer().offset)); - } - - @override - TextPosition getPositionForOffset(Offset offset) { - final local = globalToLocal(offset); - final child = childAtOffset(local)!; - - final parentData = child.parentData as BoxParentData; - final localOffset = local - parentData.offset; - final localPosition = child.getPositionForOffset(localOffset); - return TextPosition( - offset: localPosition.offset + child.getContainer().offset, - affinity: localPosition.affinity, - ); - } - - /// Returns the y-offset of the editor at which [selection] is visible. - /// - /// The offset is the distance from the top of the editor and is the minimum - /// from the current scroll position until [selection] becomes visible. - /// Returns null if [selection] is already visible. - double? getOffsetToRevealCursor( - double viewportHeight, double scrollOffset, double offsetInViewport) { - final endpoints = getEndpointsForSelection(selection); - final endpoint = endpoints.first; - final child = childAtPosition(selection.extent); - const kMargin = 8.0; - - final caretTop = endpoint.point.dy - - child.preferredLineHeight(TextPosition( - offset: selection.extentOffset - child.getContainer().offset)) - - kMargin + - offsetInViewport + - scrollBottomInset; - final caretBottom = - endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset; - double? dy; - if (caretTop < scrollOffset) { - dy = caretTop; - } else if (caretBottom > scrollOffset + viewportHeight) { - dy = caretBottom - viewportHeight; - } - if (dy == null) { - return null; - } - return math.max(dy, 0); - } -} - -class EditableContainerParentData - extends ContainerBoxParentData {} - -class RenderEditableContainerBox extends RenderBox - with - ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin { - RenderEditableContainerBox( - List? children, - this._container, - this.textDirection, - this.scrollBottomInset, - this._padding, - ) : assert(_padding.isNonNegative) { - addAll(children); - } - - container_node.Container _container; - TextDirection textDirection; - EdgeInsetsGeometry _padding; - double scrollBottomInset; - EdgeInsets? _resolvedPadding; - - container_node.Container getContainer() { - return _container; - } - - void setContainer(container_node.Container c) { - if (_container == c) { - return; - } - _container = c; - markNeedsLayout(); - } - - EdgeInsetsGeometry getPadding() => _padding; - - void setPadding(EdgeInsetsGeometry value) { - assert(value.isNonNegative); - if (_padding == value) { - return; - } - _padding = value; - _markNeedsPaddingResolution(); - } - - EdgeInsets? get resolvedPadding => _resolvedPadding; - - void _resolvePadding() { - if (_resolvedPadding != null) { - return; - } - _resolvedPadding = _padding.resolve(textDirection); - _resolvedPadding = _resolvedPadding!.copyWith(left: _resolvedPadding!.left); - - assert(_resolvedPadding!.isNonNegative); - } - - RenderEditableBox childAtPosition(TextPosition position) { - assert(firstChild != null); - - final targetNode = _container.queryChild(position.offset, false).node; - - var targetChild = firstChild; - while (targetChild != null) { - if (targetChild.getContainer() == targetNode) { - break; - } - targetChild = childAfter(targetChild); - } - if (targetChild == null) { - throw 'targetChild should not be null'; - } - return targetChild; - } - - void _markNeedsPaddingResolution() { - _resolvedPadding = null; - markNeedsLayout(); - } - - RenderEditableBox? childAtOffset(Offset offset) { - assert(firstChild != null); - _resolvePadding(); - - if (offset.dy <= _resolvedPadding!.top) { - return firstChild; - } - if (offset.dy >= size.height - _resolvedPadding!.bottom) { - return lastChild; - } - - var child = firstChild; - final dx = -offset.dx; - var dy = _resolvedPadding!.top; - while (child != null) { - if (child.size.contains(offset.translate(dx, -dy))) { - return child; - } - dy += child.size.height; - child = childAfter(child); - } - throw 'No child'; - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is EditableContainerParentData) { - return; - } - - child.parentData = EditableContainerParentData(); - } - - @override - void performLayout() { - assert(constraints.hasBoundedWidth); - _resolvePadding(); - assert(_resolvedPadding != null); - - var mainAxisExtent = _resolvedPadding!.top; - var child = firstChild; - final innerConstraints = - BoxConstraints.tightFor(width: constraints.maxWidth) - .deflate(_resolvedPadding!); - while (child != null) { - child.layout(innerConstraints, parentUsesSize: true); - final childParentData = (child.parentData as EditableContainerParentData) - ..offset = Offset(_resolvedPadding!.left, mainAxisExtent); - mainAxisExtent += child.size.height; - assert(child.parentData == childParentData); - child = childParentData.nextSibling; - } - mainAxisExtent += _resolvedPadding!.bottom; - size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); - - assert(size.isFinite); - } - - double _getIntrinsicCrossAxis(double Function(RenderBox child) childSize) { - var extent = 0.0; - var child = firstChild; - while (child != null) { - extent = math.max(extent, childSize(child)); - final childParentData = child.parentData as EditableContainerParentData; - child = childParentData.nextSibling; - } - return extent; - } - - double _getIntrinsicMainAxis(double Function(RenderBox child) childSize) { - var extent = 0.0; - var child = firstChild; - while (child != null) { - extent += childSize(child); - final childParentData = child.parentData as EditableContainerParentData; - child = childParentData.nextSibling; - } - return extent; - } - - @override - double computeMinIntrinsicWidth(double height) { - _resolvePadding(); - return _getIntrinsicCrossAxis((child) { - final childHeight = math.max( - 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); - return child.getMinIntrinsicWidth(childHeight) + - _resolvedPadding!.left + - _resolvedPadding!.right; - }); - } - - @override - double computeMaxIntrinsicWidth(double height) { - _resolvePadding(); - return _getIntrinsicCrossAxis((child) { - final childHeight = math.max( - 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); - return child.getMaxIntrinsicWidth(childHeight) + - _resolvedPadding!.left + - _resolvedPadding!.right; - }); - } - - @override - double computeMinIntrinsicHeight(double width) { - _resolvePadding(); - return _getIntrinsicMainAxis((child) { - final childWidth = math.max( - 0, width - _resolvedPadding!.left + _resolvedPadding!.right); - return child.getMinIntrinsicHeight(childWidth) + - _resolvedPadding!.top + - _resolvedPadding!.bottom; - }); - } - - @override - double computeMaxIntrinsicHeight(double width) { - _resolvePadding(); - return _getIntrinsicMainAxis((child) { - final childWidth = math.max( - 0, width - _resolvedPadding!.left + _resolvedPadding!.right); - return child.getMaxIntrinsicHeight(childWidth) + - _resolvedPadding!.top + - _resolvedPadding!.bottom; - }); - } - - @override - double computeDistanceToActualBaseline(TextBaseline baseline) { - _resolvePadding(); - return defaultComputeDistanceToFirstActualBaseline(baseline)! + - _resolvedPadding!.top; - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/editor.dart'; diff --git a/lib/widgets/image.dart b/lib/widgets/image.dart index b9df48ce..41a8a235 100644 --- a/lib/widgets/image.dart +++ b/lib/widgets/image.dart @@ -1,31 +1,3 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:photo_view/photo_view.dart'; - -class ImageTapWrapper extends StatelessWidget { - const ImageTapWrapper({ - this.imageProvider, - }); - - final ImageProvider? imageProvider; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Container( - constraints: BoxConstraints.expand( - height: MediaQuery.of(context).size.height, - ), - child: GestureDetector( - onTapDown: (_) { - Navigator.pop(context); - }, - child: PhotoView( - imageProvider: imageProvider, - ), - ), - ), - ); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/image.dart'; diff --git a/lib/widgets/keyboard_listener.dart b/lib/widgets/keyboard_listener.dart index 17c47aad..0ee8d6e7 100644 --- a/lib/widgets/keyboard_listener.dart +++ b/lib/widgets/keyboard_listener.dart @@ -1,105 +1,3 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -enum InputShortcut { CUT, COPY, PASTE, 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 { - KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete); - - 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.PASTE, - LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, - }; - - bool handleRawKeyEvent(RawKeyEvent event) { - if (kIsWeb) { - // On web platform, we should ignore the key because it's processed already. - return false; - } - - if (event is! RawKeyDownEvent) { - return false; - } - - final keysPressed = - LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); - final key = event.logicalKey; - final 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, - isMacOS ? event.isAltPressed : event.isControlPressed, - isMacOS ? event.isMetaPressed : event.isAltPressed, - 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; - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/keyboard_listener.dart'; diff --git a/lib/widgets/proxy.dart b/lib/widgets/proxy.dart index 8a04c4e1..6d17bb7d 100644 --- a/lib/widgets/proxy.dart +++ b/lib/widgets/proxy.dart @@ -1,298 +1,3 @@ -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -import 'box.dart'; - -class BaselineProxy extends SingleChildRenderObjectWidget { - const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding}) - : super(key: key, child: child); - - final TextStyle? textStyle; - final EdgeInsets? padding; - - @override - RenderBaselineProxy createRenderObject(BuildContext context) { - return RenderBaselineProxy( - null, - textStyle!, - padding, - ); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderBaselineProxy renderObject) { - renderObject - ..textStyle = textStyle! - ..padding = padding!; - } -} - -class RenderBaselineProxy extends RenderProxyBox { - RenderBaselineProxy( - RenderParagraph? child, - TextStyle textStyle, - EdgeInsets? padding, - ) : _prototypePainter = TextPainter( - text: TextSpan(text: ' ', style: textStyle), - textDirection: TextDirection.ltr, - strutStyle: - StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)), - super(child); - - final TextPainter _prototypePainter; - - set textStyle(TextStyle value) { - if (_prototypePainter.text!.style == value) { - return; - } - _prototypePainter.text = TextSpan(text: ' ', style: value); - markNeedsLayout(); - } - - EdgeInsets? _padding; - - set padding(EdgeInsets value) { - if (_padding == value) { - return; - } - _padding = value; - markNeedsLayout(); - } - - @override - double computeDistanceToActualBaseline(TextBaseline baseline) => - _prototypePainter.computeDistanceToActualBaseline(baseline); - // SEE What happens + _padding?.top; - - @override - void performLayout() { - super.performLayout(); - _prototypePainter.layout(); - } -} - -class EmbedProxy extends SingleChildRenderObjectWidget { - const EmbedProxy(Widget child) : super(child: child); - - @override - RenderEmbedProxy createRenderObject(BuildContext context) => - RenderEmbedProxy(null); -} - -class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { - RenderEmbedProxy(RenderBox? child) : super(child); - - @override - List getBoxesForSelection(TextSelection selection) { - if (!selection.isCollapsed) { - return [ - TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr) - ]; - } - - final left = selection.extentOffset == 0 ? 0.0 : size.width; - final right = selection.extentOffset == 0 ? 0.0 : size.width; - return [ - TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr) - ]; - } - - @override - double getFullHeightForCaret(TextPosition position) => size.height; - - @override - Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) { - assert(position.offset <= 1 && position.offset >= 0); - return position.offset == 0 ? Offset.zero : Offset(size.width, 0); - } - - @override - TextPosition getPositionForOffset(Offset offset) => - TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0); - - @override - TextRange getWordBoundary(TextPosition position) => - const TextRange(start: 0, end: 1); - - @override - double getPreferredLineHeight() { - return size.height; - } -} - -class RichTextProxy extends SingleChildRenderObjectWidget { - const RichTextProxy( - RichText child, - this.textStyle, - this.textAlign, - this.textDirection, - this.textScaleFactor, - this.locale, - this.strutStyle, - this.textWidthBasis, - this.textHeightBehavior, - ) : super(child: child); - - final TextStyle textStyle; - final TextAlign textAlign; - final TextDirection textDirection; - final double textScaleFactor; - final Locale locale; - final StrutStyle strutStyle; - final TextWidthBasis textWidthBasis; - final TextHeightBehavior? textHeightBehavior; - - @override - RenderParagraphProxy createRenderObject(BuildContext context) { - return RenderParagraphProxy( - null, - textStyle, - textAlign, - textDirection, - textScaleFactor, - strutStyle, - locale, - textWidthBasis, - textHeightBehavior); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderParagraphProxy renderObject) { - renderObject - ..textStyle = textStyle - ..textAlign = textAlign - ..textDirection = textDirection - ..textScaleFactor = textScaleFactor - ..locale = locale - ..strutStyle = strutStyle - ..textWidthBasis = textWidthBasis - ..textHeightBehavior = textHeightBehavior; - } -} - -class RenderParagraphProxy extends RenderProxyBox - implements RenderContentProxyBox { - RenderParagraphProxy( - RenderParagraph? child, - TextStyle textStyle, - TextAlign textAlign, - TextDirection textDirection, - double textScaleFactor, - StrutStyle strutStyle, - Locale locale, - TextWidthBasis textWidthBasis, - TextHeightBehavior? textHeightBehavior, - ) : _prototypePainter = TextPainter( - text: TextSpan(text: ' ', style: textStyle), - textAlign: textAlign, - textDirection: textDirection, - textScaleFactor: textScaleFactor, - strutStyle: strutStyle, - locale: locale, - textWidthBasis: textWidthBasis, - textHeightBehavior: textHeightBehavior), - super(child); - - final TextPainter _prototypePainter; - - set textStyle(TextStyle value) { - if (_prototypePainter.text!.style == value) { - return; - } - _prototypePainter.text = TextSpan(text: ' ', style: value); - markNeedsLayout(); - } - - set textAlign(TextAlign value) { - if (_prototypePainter.textAlign == value) { - return; - } - _prototypePainter.textAlign = value; - markNeedsLayout(); - } - - set textDirection(TextDirection value) { - if (_prototypePainter.textDirection == value) { - return; - } - _prototypePainter.textDirection = value; - markNeedsLayout(); - } - - set textScaleFactor(double value) { - if (_prototypePainter.textScaleFactor == value) { - return; - } - _prototypePainter.textScaleFactor = value; - markNeedsLayout(); - } - - set strutStyle(StrutStyle value) { - if (_prototypePainter.strutStyle == value) { - return; - } - _prototypePainter.strutStyle = value; - markNeedsLayout(); - } - - set locale(Locale value) { - if (_prototypePainter.locale == value) { - return; - } - _prototypePainter.locale = value; - markNeedsLayout(); - } - - set textWidthBasis(TextWidthBasis value) { - if (_prototypePainter.textWidthBasis == value) { - return; - } - _prototypePainter.textWidthBasis = value; - markNeedsLayout(); - } - - set textHeightBehavior(TextHeightBehavior? value) { - if (_prototypePainter.textHeightBehavior == value) { - return; - } - _prototypePainter.textHeightBehavior = value; - markNeedsLayout(); - } - - @override - RenderParagraph? get child => super.child as RenderParagraph?; - - @override - double getPreferredLineHeight() { - return _prototypePainter.preferredLineHeight; - } - - @override - Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) => - child!.getOffsetForCaret(position, caretPrototype!); - - @override - TextPosition getPositionForOffset(Offset offset) => - child!.getPositionForOffset(offset); - - @override - double? getFullHeightForCaret(TextPosition position) => - child!.getFullHeightForCaret(position); - - @override - TextRange getWordBoundary(TextPosition position) => - child!.getWordBoundary(position); - - @override - List getBoxesForSelection(TextSelection selection) => - child!.getBoxesForSelection(selection); - - @override - void performLayout() { - super.performLayout(); - _prototypePainter.layout( - minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/proxy.dart'; diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index cd4f379c..cf483dd1 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -1,736 +1,3 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.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/line.dart'; -import 'controller.dart'; -import 'cursor.dart'; -import 'default_styles.dart'; -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'; - -class RawEditor extends StatefulWidget { - const RawEditor( - Key key, - this.controller, - this.focusNode, - this.scrollController, - this.scrollable, - this.scrollBottomInset, - this.padding, - this.readOnly, - this.placeholder, - this.onLaunchUrl, - this.toolbarOptions, - this.showSelectionHandles, - bool? showCursor, - this.cursorStyle, - this.textCapitalization, - this.maxHeight, - this.minHeight, - this.customStyles, - this.expands, - this.autoFocus, - this.selectionColor, - this.selectionCtrls, - this.keyboardAppearance, - this.enableInteractiveSelection, - this.scrollPhysics, - this.embedBuilder, - ) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), - assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), - assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, - 'maxHeight cannot be null'), - showCursor = showCursor ?? true, - super(key: key); - - final QuillController controller; - final FocusNode focusNode; - final ScrollController scrollController; - final bool scrollable; - final double scrollBottomInset; - final EdgeInsetsGeometry padding; - final bool readOnly; - final String? placeholder; - final ValueChanged? onLaunchUrl; - final ToolbarOptions toolbarOptions; - final bool showSelectionHandles; - final bool showCursor; - final CursorStyle cursorStyle; - final TextCapitalization textCapitalization; - final double? maxHeight; - final double? minHeight; - final DefaultStyles? customStyles; - final bool expands; - final bool autoFocus; - final Color selectionColor; - final TextSelectionControls selectionCtrls; - final Brightness keyboardAppearance; - final bool enableInteractiveSelection; - final ScrollPhysics? scrollPhysics; - final EmbedBuilder embedBuilder; - - @override - State createState() => RawEditorState(); -} - -class RawEditorState extends EditorState - with - AutomaticKeepAliveClientMixin, - WidgetsBindingObserver, - TickerProviderStateMixin, - RawEditorStateKeyboardMixin, - RawEditorStateTextInputClientMixin, - RawEditorStateSelectionDelegateMixin { - final GlobalKey _editorKey = GlobalKey(); - - // Keyboard - late KeyboardListener _keyboardListener; - KeyboardVisibilityController? _keyboardVisibilityController; - StreamSubscription? _keyboardVisibilitySubscription; - bool _keyboardVisible = false; - - // Selection overlay - @override - EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; - EditorTextSelectionOverlay? _selectionOverlay; - - ScrollController? _scrollController; - - late CursorCont _cursorCont; - - // Focus - bool _didAutoFocus = false; - FocusAttachment? _focusAttachment; - bool get _hasFocus => widget.focusNode.hasFocus; - - DefaultStyles? _styles; - - final ClipboardStatusNotifier? _clipboardStatus = - kIsWeb ? null : ClipboardStatusNotifier(); - final LayerLink _toolbarLayerLink = LayerLink(); - final LayerLink _startHandleLayerLink = LayerLink(); - final LayerLink _endHandleLayerLink = LayerLink(); - - TextDirection get _textDirection => Directionality.of(context); - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - _focusAttachment!.reparent(); - super.build(context); - - var _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: _Editor( - key: _editorKey, - document: _doc, - selection: widget.controller.selection, - hasFocus: _hasFocus, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - scrollBottomInset: widget.scrollBottomInset, - padding: widget.padding, - children: _buildChildren(_doc, context), - ), - ), - ); - - if (widget.scrollable) { - final baselinePadding = - EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1); - child = BaselineProxy( - textStyle: _styles!.paragraph!.style, - padding: baselinePadding, - child: SingleChildScrollView( - controller: _scrollController, - physics: widget.scrollPhysics, - child: child, - ), - ); - } - - final constraints = widget.expands - ? const BoxConstraints.expand() - : BoxConstraints( - minHeight: widget.minHeight ?? 0.0, - maxHeight: widget.maxHeight ?? double.infinity); - - return QuillStyles( - data: _styles!, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: Container( - constraints: constraints, - child: child, - ), - ), - ); - } - - void _handleSelectionChanged( - TextSelection selection, SelectionChangedCause cause) { - widget.controller.updateSelection(selection, ChangeSource.LOCAL); - - _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); - - if (!_keyboardVisible) { - requestKeyboard(); - } - } - - /// 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 = {}; - 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, - 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.'); - } - } - return result; - } - - EditableTextLine _getEditableTextLineFromNode( - Line node, BuildContext context) { - final textLine = TextLine( - line: node, - textDirection: _textDirection, - embedBuilder: widget.embedBuilder, - styles: _styles!, - ); - final editableTextLine = EditableTextLine( - node, - null, - textLine, - 0, - _getVerticalSpacingForLine(node, _styles), - _textDirection, - widget.controller.selection, - widget.selectionColor, - widget.enableInteractiveSelection, - _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; - } - - @override - void initState() { - super.initState(); - - _clipboardStatus?.addListener(_onChangedClipboardStatus); - - widget.controller.addListener(() { - _didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange); - }); - - _scrollController = widget.scrollController; - _scrollController!.addListener(_updateSelectionOverlayForScroll); - - _cursorCont = CursorCont( - show: ValueNotifier(widget.showCursor), - style: widget.cursorStyle, - tickerProvider: this, - ); - - _keyboardListener = KeyboardListener( - handleCursorMovement, - handleShortcut, - handleDelete, - ); - - if (defaultTargetPlatform == TargetPlatform.windows || - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.linux || - defaultTargetPlatform == TargetPlatform.fuchsia) { - _keyboardVisible = true; - } else { - _keyboardVisibilityController = KeyboardVisibilityController(); - _keyboardVisible = _keyboardVisibilityController!.isVisible; - _keyboardVisibilitySubscription = - _keyboardVisibilityController?.onChange.listen((visible) { - _keyboardVisible = visible; - if (visible) { - _onChangeTextEditingValue(); - } - }); - } - - _focusAttachment = widget.focusNode.attach(context, - onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); - widget.focusNode.addListener(_handleFocusChanged); - } - - @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!); - } - - if (!_didAutoFocus && widget.autoFocus) { - FocusScope.of(context).autofocus(widget.focusNode); - _didAutoFocus = true; - } - } - - @override - void didUpdateWidget(RawEditor oldWidget) { - super.didUpdateWidget(oldWidget); - - _cursorCont.show.value = widget.showCursor; - _cursorCont.style = widget.cursorStyle; - - if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_didChangeTextEditingValue); - widget.controller.addListener(_didChangeTextEditingValue); - updateRemoteValueIfNeeded(); - } - - if (widget.scrollController != _scrollController) { - _scrollController!.removeListener(_updateSelectionOverlayForScroll); - _scrollController = widget.scrollController; - _scrollController!.addListener(_updateSelectionOverlayForScroll); - } - - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode.removeListener(_handleFocusChanged); - _focusAttachment?.detach(); - _focusAttachment = widget.focusNode.attach(context, - onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); - widget.focusNode.addListener(_handleFocusChanged); - updateKeepAlive(); - } - - if (widget.controller.selection != oldWidget.controller.selection) { - _selectionOverlay?.update(textEditingValue); - } - - _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); - if (!shouldCreateInputConnection) { - closeConnectionIfNeeded(); - } else { - if (oldWidget.readOnly && _hasFocus) { - openConnectionIfNeeded(); - } - } - } - - bool _shouldShowSelectionHandles() { - return widget.showSelectionHandles && - !widget.controller.selection.isCollapsed; - } - - @override - void dispose() { - closeConnectionIfNeeded(); - _keyboardVisibilitySubscription?.cancel(); - assert(!hasConnection); - _selectionOverlay?.dispose(); - _selectionOverlay = null; - widget.controller.removeListener(_didChangeTextEditingValue); - widget.focusNode.removeListener(_handleFocusChanged); - _focusAttachment!.detach(); - _cursorCont.dispose(); - _clipboardStatus?.removeListener(_onChangedClipboardStatus); - _clipboardStatus?.dispose(); - super.dispose(); - } - - void _updateSelectionOverlayForScroll() { - _selectionOverlay?.markNeedsBuild(); - } - - void _didChangeTextEditingValue([bool ignoreFocus = false]) { - if (kIsWeb) { - _onChangeTextEditingValue(ignoreFocus); - if (!ignoreFocus) { - requestKeyboard(); - } - return; - } - - if (ignoreFocus || _keyboardVisible) { - _onChangeTextEditingValue(ignoreFocus); - } else { - requestKeyboard(); - if (mounted) { - setState(() { - // Use widget.controller.value in build() - // Trigger build and updateChildren - }); - } - } - } - - void _onChangeTextEditingValue([bool ignoreCaret = false]) { - updateRemoteValueIfNeeded(); - if (ignoreCaret) { - return; - } - _showCaretOnScreen(); - _cursorCont.startOrStopCursorTimerIfNeeded( - _hasFocus, widget.controller.selection); - if (hasConnection) { - _cursorCont - ..stopCursorTimer(resetCharTicks: false) - ..startCursorTimer(); - } - - SchedulerBinding.instance!.addPostFrameCallback( - (_) => _updateOrDisposeSelectionOverlayIfNeeded()); - if (mounted) { - setState(() { - // Use widget.controller.value in build() - // Trigger build and updateChildren - }); - } - } - - void _updateOrDisposeSelectionOverlayIfNeeded() { - if (_selectionOverlay != null) { - if (_hasFocus) { - _selectionOverlay!.update(textEditingValue); - } else { - _selectionOverlay!.dispose(); - _selectionOverlay = null; - } - } else if (_hasFocus) { - _selectionOverlay?.hide(); - _selectionOverlay = null; - - _selectionOverlay = EditorTextSelectionOverlay( - textEditingValue, - false, - context, - widget, - _toolbarLayerLink, - _startHandleLayerLink, - _endHandleLayerLink, - getRenderEditor(), - widget.selectionCtrls, - this, - DragStartBehavior.start, - null, - _clipboardStatus!, - ); - _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); - _selectionOverlay!.showHandles(); - } - } - - void _handleFocusChanged() { - openOrCloseConnection(); - _cursorCont.startOrStopCursorTimerIfNeeded( - _hasFocus, widget.controller.selection); - _updateOrDisposeSelectionOverlayIfNeeded(); - if (_hasFocus) { - WidgetsBinding.instance!.addObserver(this); - _showCaretOnScreen(); - } else { - WidgetsBinding.instance!.removeObserver(this); - } - updateKeepAlive(); - } - - void _onChangedClipboardStatus() { - if (!mounted) return; - setState(() { - // Inform the widget that the value of clipboardStatus has changed. - // Trigger build and updateChildren - }); - } - - bool _showCaretOnScreenScheduled = false; - - void _showCaretOnScreen() { - if (!widget.showCursor || _showCaretOnScreenScheduled) { - return; - } - - _showCaretOnScreenScheduled = true; - SchedulerBinding.instance!.addPostFrameCallback((_) { - 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 offset = getRenderEditor()!.getOffsetToRevealCursor( - _scrollController!.position.viewportDimension, - _scrollController!.offset, - offsetInViewport, - ); - - if (offset != null) { - _scrollController!.animateTo( - offset, - duration: const Duration(milliseconds: 100), - curve: Curves.fastOutSlowIn, - ); - } - } - }); - } - - @override - RenderEditor? getRenderEditor() { - return _editorKey.currentContext!.findRenderObject() as RenderEditor?; - } - - @override - TextEditingValue getTextEditingValue() { - return widget.controller.plainTextEditingValue; - } - - @override - void requestKeyboard() { - if (_hasFocus) { - openConnectionIfNeeded(); - } else { - widget.focusNode.requestFocus(); - } - } - - @override - void setTextEditingValue(TextEditingValue value) { - if (value.text == textEditingValue.text) { - widget.controller.updateSelection(value.selection, ChangeSource.LOCAL); - } else { - __setEditingValue(value); - } - } - - Future __setEditingValue(TextEditingValue value) async { - if (await __isItCut(value)) { - widget.controller.replaceText( - textEditingValue.selection.start, - textEditingValue.text.length - value.text.length, - '', - value.selection, - ); - } else { - final value = textEditingValue; - final data = await Clipboard.getData(Clipboard.kTextPlain); - if (data != null) { - final length = - textEditingValue.selection.end - textEditingValue.selection.start; - widget.controller.replaceText( - value.selection.start, - length, - data.text, - value.selection, - ); - // move cursor to the end of pasted text selection - widget.controller.updateSelection( - TextSelection.collapsed( - offset: value.selection.start + data.text!.length), - ChangeSource.LOCAL); - } - } - } - - Future __isItCut(TextEditingValue value) async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - if (data == null) { - return false; - } - return textEditingValue.text.length - value.text.length == - data.text!.length; - } - - @override - bool showToolbar() { - // Web is using native dom elements to enable clipboard functionality of the - // toolbar: copy, paste, select, cut. It might also provide additional - // functionality depending on the browser (such as translate). Due to this - // we should not show a Flutter toolbar for the editable text elements. - if (kIsWeb) { - return false; - } - if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) { - return false; - } - - _selectionOverlay!.update(textEditingValue); - _selectionOverlay!.showToolbar(); - return true; - } - - @override - bool get wantKeepAlive => widget.focusNode.hasFocus; - - @override - void userUpdateTextEditingValue( - TextEditingValue value, SelectionChangedCause cause) { - // TODO: implement userUpdateTextEditingValue - } -} - -class _Editor extends MultiChildRenderObjectWidget { - _Editor({ - required Key key, - required List children, - required this.document, - required this.textDirection, - required this.hasFocus, - required this.selection, - required this.startHandleLayerLink, - required this.endHandleLayerLink, - required this.onSelectionChanged, - required this.scrollBottomInset, - this.padding = EdgeInsets.zero, - }) : super(key: key, children: children); - - final Document document; - final TextDirection textDirection; - final bool hasFocus; - final TextSelection selection; - 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, - selection, - 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 - ..setHasFocus(hasFocus) - ..setSelection(selection) - ..setStartHandleLayerLink(startHandleLayerLink) - ..setEndHandleLayerLink(endHandleLayerLink) - ..onSelectionChanged = onSelectionChanged - ..setScrollBottomInset(scrollBottomInset) - ..setPadding(padding); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/raw_editor.dart'; diff --git a/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart b/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart index 0eb7f955..ab326cec 100644 --- a/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart +++ b/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart @@ -1,354 +1,3 @@ -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)); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart'; 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 index cda991cc..8ff17a7f 100644 --- a/lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -1,40 +1,3 @@ -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; -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart'; 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 index 527df582..e9ea33c5 100644 --- 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 @@ -1,200 +1,3 @@ -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(); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart'; diff --git a/lib/widgets/responsive_widget.dart b/lib/widgets/responsive_widget.dart index 3829565c..bb46820f 100644 --- a/lib/widgets/responsive_widget.dart +++ b/lib/widgets/responsive_widget.dart @@ -1,43 +1,3 @@ -import 'package:flutter/material.dart'; - -class ResponsiveWidget extends StatelessWidget { - const ResponsiveWidget({ - required this.largeScreen, - this.mediumScreen, - this.smallScreen, - Key? key, - }) : super(key: key); - - final Widget largeScreen; - final Widget? mediumScreen; - final Widget? smallScreen; - - static bool isSmallScreen(BuildContext context) { - return MediaQuery.of(context).size.width < 800; - } - - static bool isLargeScreen(BuildContext context) { - return MediaQuery.of(context).size.width > 1200; - } - - static bool isMediumScreen(BuildContext context) { - return MediaQuery.of(context).size.width >= 800 && - MediaQuery.of(context).size.width <= 1200; - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 1200) { - return largeScreen; - } else if (constraints.maxWidth <= 1200 && - constraints.maxWidth >= 800) { - return mediumScreen ?? largeScreen; - } else { - return smallScreen ?? largeScreen; - } - }, - ); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/responsive_widget.dart'; diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart index ee1a7732..219babec 100644 --- a/lib/widgets/simple_viewer.dart +++ b/lib/widgets/simple_viewer.dart @@ -1,344 +1,3 @@ -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); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/simple_viewer.dart'; diff --git a/lib/widgets/text_block.dart b/lib/widgets/text_block.dart index f533a160..3e46ea80 100644 --- a/lib/widgets/text_block.dart +++ b/lib/widgets/text_block.dart @@ -1,737 +1,3 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:tuple/tuple.dart'; - -import '../models/documents/attribute.dart'; -import '../models/documents/nodes/block.dart'; -import '../models/documents/nodes/line.dart'; -import 'box.dart'; -import 'cursor.dart'; -import 'default_styles.dart'; -import 'delegate.dart'; -import 'editor.dart'; -import 'text_line.dart'; -import 'text_selection.dart'; - -const List arabianRomanNumbers = [ - 1000, - 900, - 500, - 400, - 100, - 90, - 50, - 40, - 10, - 9, - 5, - 4, - 1 -]; - -const List romanNumbers = [ - 'M', - 'CM', - 'D', - 'CD', - 'C', - 'XC', - 'L', - 'XL', - 'X', - 'IX', - 'V', - 'IV', - 'I' -]; - -class EditableTextBlock extends StatelessWidget { - const EditableTextBlock( - this.block, - this.textDirection, - this.scrollBottomInset, - this.verticalSpacing, - this.textSelection, - this.color, - this.styles, - this.enableInteractiveSelection, - this.hasFocus, - this.contentPadding, - this.embedBuilder, - this.cursorCont, - this.indentLevelCounts, - this.onCheckboxTap, - ); - - final Block block; - final TextDirection textDirection; - final double scrollBottomInset; - final Tuple2 verticalSpacing; - final TextSelection textSelection; - final Color color; - final DefaultStyles? styles; - final bool enableInteractiveSelection; - final bool hasFocus; - final EdgeInsets? contentPadding; - final EmbedBuilder embedBuilder; - final CursorCont cursorCont; - final Map indentLevelCounts; - final Function(int, bool) onCheckboxTap; - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - - final defaultStyles = QuillStyles.getStyles(context, false); - return _EditableBlock( - block, - textDirection, - verticalSpacing as Tuple2, - scrollBottomInset, - _getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(), - contentPadding, - _buildChildren(context, indentLevelCounts)); - } - - BoxDecoration? _getDecorationForBlock( - Block node, DefaultStyles? defaultStyles) { - final attrs = block.style.attributes; - if (attrs.containsKey(Attribute.blockQuote.key)) { - return defaultStyles!.quote!.decoration; - } - if (attrs.containsKey(Attribute.codeBlock.key)) { - return defaultStyles!.code!.decoration; - } - return null; - } - - List _buildChildren( - BuildContext context, Map indentLevelCounts) { - final defaultStyles = QuillStyles.getStyles(context, false); - final count = block.children.length; - final children = []; - var index = 0; - for (final line in Iterable.castFrom(block.children)) { - index++; - final editableTextLine = EditableTextLine( - line, - _buildLeading(context, line, index, indentLevelCounts, count), - TextLine( - line: line, - textDirection: textDirection, - embedBuilder: embedBuilder, - styles: styles!, - ), - _getIndentWidth(), - _getSpacingForLine(line, index, count, defaultStyles), - textDirection, - textSelection, - color, - enableInteractiveSelection, - hasFocus, - MediaQuery.of(context).devicePixelRatio, - cursorCont); - children.add(editableTextLine); - } - return children.toList(growable: false); - } - - Widget? _buildLeading(BuildContext context, Line line, int index, - Map indentLevelCounts, int count) { - final defaultStyles = QuillStyles.getStyles(context, false); - final attrs = line.style.attributes; - if (attrs[Attribute.list.key] == Attribute.ol) { - return _NumberPoint( - index: index, - indentLevelCounts: indentLevelCounts, - count: count, - style: defaultStyles!.leading!.style, - attrs: attrs, - width: 32, - padding: 8, - ); - } - - if (attrs[Attribute.list.key] == Attribute.ul) { - return _BulletPoint( - style: - defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), - width: 32, - ); - } - - if (attrs[Attribute.list.key] == Attribute.checked) { - return _Checkbox( - 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( - key: UniqueKey(), - style: defaultStyles!.leading!.style, - width: 32, - offset: block.offset + line.offset, - onTap: onCheckboxTap, - ); - } - - if (attrs.containsKey(Attribute.codeBlock.key)) { - return _NumberPoint( - index: index, - indentLevelCounts: indentLevelCounts, - count: count, - style: defaultStyles!.code!.style - .copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)), - width: 32, - attrs: attrs, - padding: 16, - withDot: false, - ); - } - return null; - } - - double _getIndentWidth() { - final attrs = block.style.attributes; - - final indent = attrs[Attribute.indent.key]; - var extraIndent = 0.0; - if (indent != null && indent.value != null) { - extraIndent = 16.0 * indent.value; - } - - if (attrs.containsKey(Attribute.blockQuote.key)) { - return 16.0 + extraIndent; - } - - return 32.0 + extraIndent; - } - - Tuple2 _getSpacingForLine( - Line node, int index, int count, DefaultStyles? defaultStyles) { - var top = 0.0, bottom = 0.0; - - final attrs = block.style.attributes; - if (attrs.containsKey(Attribute.header.key)) { - final level = attrs[Attribute.header.key]!.value; - switch (level) { - case 1: - top = defaultStyles!.h1!.verticalSpacing.item1; - bottom = defaultStyles.h1!.verticalSpacing.item2; - break; - case 2: - top = defaultStyles!.h2!.verticalSpacing.item1; - bottom = defaultStyles.h2!.verticalSpacing.item2; - break; - case 3: - top = defaultStyles!.h3!.verticalSpacing.item1; - bottom = defaultStyles.h3!.verticalSpacing.item2; - break; - default: - throw 'Invalid level $level'; - } - } else { - late Tuple2 lineSpacing; - if (attrs.containsKey(Attribute.blockQuote.key)) { - lineSpacing = defaultStyles!.quote!.lineSpacing; - } else if (attrs.containsKey(Attribute.indent.key)) { - lineSpacing = defaultStyles!.indent!.lineSpacing; - } else if (attrs.containsKey(Attribute.list.key)) { - lineSpacing = defaultStyles!.lists!.lineSpacing; - } else if (attrs.containsKey(Attribute.codeBlock.key)) { - lineSpacing = defaultStyles!.code!.lineSpacing; - } else if (attrs.containsKey(Attribute.align.key)) { - lineSpacing = defaultStyles!.align!.lineSpacing; - } - top = lineSpacing.item1; - bottom = lineSpacing.item2; - } - - if (index == 1) { - top = 0.0; - } - - if (index == count) { - bottom = 0.0; - } - - return Tuple2(top, bottom); - } -} - -class RenderEditableTextBlock extends RenderEditableContainerBox - implements RenderEditableBox { - RenderEditableTextBlock({ - required Block block, - required TextDirection textDirection, - required EdgeInsetsGeometry padding, - required double scrollBottomInset, - required Decoration decoration, - List? children, - ImageConfiguration configuration = ImageConfiguration.empty, - EdgeInsets contentPadding = EdgeInsets.zero, - }) : _decoration = decoration, - _configuration = configuration, - _savedPadding = padding, - _contentPadding = contentPadding, - super( - children, - block, - textDirection, - scrollBottomInset, - padding.add(contentPadding), - ); - - EdgeInsetsGeometry _savedPadding; - EdgeInsets _contentPadding; - - set contentPadding(EdgeInsets value) { - if (_contentPadding == value) return; - _contentPadding = value; - super.setPadding(_savedPadding.add(_contentPadding)); - } - - @override - void setPadding(EdgeInsetsGeometry value) { - super.setPadding(value.add(_contentPadding)); - _savedPadding = value; - } - - BoxPainter? _painter; - - Decoration get decoration => _decoration; - Decoration _decoration; - - set decoration(Decoration value) { - if (value == _decoration) return; - _painter?.dispose(); - _painter = null; - _decoration = value; - markNeedsPaint(); - } - - ImageConfiguration get configuration => _configuration; - ImageConfiguration _configuration; - - set configuration(ImageConfiguration value) { - if (value == _configuration) return; - _configuration = value; - markNeedsPaint(); - } - - @override - TextRange getLineBoundary(TextPosition position) { - final child = childAtPosition(position); - final rangeInChild = child.getLineBoundary(TextPosition( - offset: position.offset - child.getContainer().offset, - affinity: position.affinity, - )); - return TextRange( - start: rangeInChild.start + child.getContainer().offset, - end: rangeInChild.end + child.getContainer().offset, - ); - } - - @override - Offset getOffsetForCaret(TextPosition position) { - final child = childAtPosition(position); - return child.getOffsetForCaret(TextPosition( - offset: position.offset - child.getContainer().offset, - affinity: position.affinity, - )) + - (child.parentData as BoxParentData).offset; - } - - @override - TextPosition getPositionForOffset(Offset offset) { - final child = childAtOffset(offset)!; - final parentData = child.parentData as BoxParentData; - final localPosition = - child.getPositionForOffset(offset - parentData.offset); - return TextPosition( - offset: localPosition.offset + child.getContainer().offset, - affinity: localPosition.affinity, - ); - } - - @override - TextRange getWordBoundary(TextPosition position) { - final child = childAtPosition(position); - final nodeOffset = child.getContainer().offset; - final childWord = child - .getWordBoundary(TextPosition(offset: position.offset - nodeOffset)); - return TextRange( - start: childWord.start + nodeOffset, - end: childWord.end + nodeOffset, - ); - } - - @override - TextPosition? getPositionAbove(TextPosition position) { - assert(position.offset < getContainer().length); - - final child = childAtPosition(position); - final childLocalPosition = - TextPosition(offset: position.offset - child.getContainer().offset); - final result = child.getPositionAbove(childLocalPosition); - if (result != null) { - return TextPosition(offset: result.offset + child.getContainer().offset); - } - - final sibling = childBefore(child); - if (sibling == null) { - return null; - } - - final caretOffset = child.getOffsetForCaret(childLocalPosition); - final testPosition = - TextPosition(offset: sibling.getContainer().length - 1); - final testOffset = sibling.getOffsetForCaret(testPosition); - final finalOffset = Offset(caretOffset.dx, testOffset.dy); - return TextPosition( - offset: sibling.getContainer().offset + - sibling.getPositionForOffset(finalOffset).offset); - } - - @override - TextPosition? getPositionBelow(TextPosition position) { - assert(position.offset < getContainer().length); - - final child = childAtPosition(position); - final childLocalPosition = - TextPosition(offset: position.offset - child.getContainer().offset); - final result = child.getPositionBelow(childLocalPosition); - if (result != null) { - return TextPosition(offset: result.offset + child.getContainer().offset); - } - - final sibling = childAfter(child); - if (sibling == null) { - return null; - } - - final caretOffset = child.getOffsetForCaret(childLocalPosition); - final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0)); - final finalOffset = Offset(caretOffset.dx, testOffset.dy); - return TextPosition( - offset: sibling.getContainer().offset + - sibling.getPositionForOffset(finalOffset).offset); - } - - @override - double preferredLineHeight(TextPosition position) { - final child = childAtPosition(position); - return child.preferredLineHeight( - TextPosition(offset: position.offset - child.getContainer().offset)); - } - - @override - TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) { - if (selection.isCollapsed) { - return TextSelectionPoint( - Offset(0, preferredLineHeight(selection.extent)) + - getOffsetForCaret(selection.extent), - null); - } - - final baseNode = getContainer().queryChild(selection.start, false).node; - var baseChild = firstChild; - while (baseChild != null) { - if (baseChild.getContainer() == baseNode) { - break; - } - baseChild = childAfter(baseChild); - } - assert(baseChild != null); - - final basePoint = baseChild!.getBaseEndpointForSelection( - localSelection(baseChild.getContainer(), selection, true)); - return TextSelectionPoint( - basePoint.point + (baseChild.parentData as BoxParentData).offset, - basePoint.direction); - } - - @override - TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) { - if (selection.isCollapsed) { - return TextSelectionPoint( - Offset(0, preferredLineHeight(selection.extent)) + - getOffsetForCaret(selection.extent), - null); - } - - final extentNode = getContainer().queryChild(selection.end, false).node; - - var extentChild = firstChild; - while (extentChild != null) { - if (extentChild.getContainer() == extentNode) { - break; - } - extentChild = childAfter(extentChild); - } - assert(extentChild != null); - - final extentPoint = extentChild!.getExtentEndpointForSelection( - localSelection(extentChild.getContainer(), selection, true)); - return TextSelectionPoint( - extentPoint.point + (extentChild.parentData as BoxParentData).offset, - extentPoint.direction); - } - - @override - void detach() { - _painter?.dispose(); - _painter = null; - super.detach(); - markNeedsPaint(); - } - - @override - void paint(PaintingContext context, Offset offset) { - _paintDecoration(context, offset); - defaultPaint(context, offset); - } - - void _paintDecoration(PaintingContext context, Offset offset) { - _painter ??= _decoration.createBoxPainter(markNeedsPaint); - - final decorationPadding = resolvedPadding! - _contentPadding; - - final filledConfiguration = - configuration.copyWith(size: decorationPadding.deflateSize(size)); - final debugSaveCount = context.canvas.getSaveCount(); - - final decorationOffset = - offset.translate(decorationPadding.left, decorationPadding.top); - _painter!.paint(context.canvas, decorationOffset, filledConfiguration); - if (debugSaveCount != context.canvas.getSaveCount()) { - throw '${_decoration.runtimeType} painter had mismatching save and restore calls.'; - } - if (decoration.isComplex) { - context.setIsComplexHint(); - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } -} - -class _EditableBlock extends MultiChildRenderObjectWidget { - _EditableBlock( - this.block, - this.textDirection, - this.padding, - this.scrollBottomInset, - this.decoration, - this.contentPadding, - List children) - : super(children: children); - - final Block block; - final TextDirection textDirection; - final Tuple2 padding; - final double scrollBottomInset; - final Decoration decoration; - final EdgeInsets? contentPadding; - - EdgeInsets get _padding => - EdgeInsets.only(top: padding.item1, bottom: padding.item2); - - EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; - - @override - RenderEditableTextBlock createRenderObject(BuildContext context) { - return RenderEditableTextBlock( - block: block, - textDirection: textDirection, - padding: _padding, - scrollBottomInset: scrollBottomInset, - decoration: decoration, - contentPadding: _contentPadding, - ); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderEditableTextBlock renderObject) { - renderObject - ..setContainer(block) - ..textDirection = textDirection - ..scrollBottomInset = scrollBottomInset - ..setPadding(_padding) - ..decoration = decoration - ..contentPadding = _contentPadding; - } -} - -class _NumberPoint extends StatelessWidget { - const _NumberPoint({ - required this.index, - required this.indentLevelCounts, - required this.count, - required this.style, - required this.width, - required this.attrs, - this.withDot = true, - this.padding = 0.0, - Key? key, - }) : super(key: key); - - final int index; - final Map indentLevelCounts; - final int count; - final TextStyle style; - final double width; - final Map attrs; - final bool withDot; - final double padding; - - @override - Widget build(BuildContext context) { - var s = index.toString(); - int? level = 0; - if (!attrs.containsKey(Attribute.indent.key) && - !indentLevelCounts.containsKey(1)) { - indentLevelCounts.clear(); - return Container( - alignment: AlignmentDirectional.topEnd, - width: width, - padding: EdgeInsetsDirectional.only(end: padding), - child: Text(withDot ? '$s.' : s, style: style), - ); - } - if (attrs.containsKey(Attribute.indent.key)) { - level = attrs[Attribute.indent.key]!.value; - } else { - // first level but is back from previous indent level - // supposed to be "2." - indentLevelCounts[0] = 1; - } - if (indentLevelCounts.containsKey(level! + 1)) { - // last visited level is done, going up - indentLevelCounts.remove(level + 1); - } - final count = (indentLevelCounts[level] ?? 0) + 1; - indentLevelCounts[level] = count; - - s = count.toString(); - if (level % 3 == 1) { - // a. b. c. d. e. ... - s = _toExcelSheetColumnTitle(count); - } else if (level % 3 == 2) { - // i. ii. iii. ... - s = _intToRoman(count); - } - // level % 3 == 0 goes back to 1. 2. 3. - - return Container( - alignment: AlignmentDirectional.topEnd, - width: width, - padding: EdgeInsetsDirectional.only(end: padding), - child: Text(withDot ? '$s.' : s, style: style), - ); - } - - String _toExcelSheetColumnTitle(int n) { - final result = StringBuffer(); - while (n > 0) { - n--; - result.write(String.fromCharCode((n % 26).floor() + 97)); - n = (n / 26).floor(); - } - - return result.toString().split('').reversed.join(); - } - - String _intToRoman(int input) { - var num = input; - - if (num < 0) { - return ''; - } else if (num == 0) { - return 'nulla'; - } - - final builder = StringBuffer(); - for (var a = 0; a < arabianRomanNumbers.length; a++) { - final times = (num / arabianRomanNumbers[a]) - .truncate(); // equals 1 only when arabianRomanNumbers[a] = num - // executes n times where n is the number of times you have to add - // the current roman number value to reach current num. - builder.write(romanNumbers[a] * times); - num -= times * - arabianRomanNumbers[ - a]; // subtract previous roman number value from num - } - - return builder.toString().toLowerCase(); - } -} - -class _BulletPoint extends StatelessWidget { - const _BulletPoint({ - required this.style, - required this.width, - Key? key, - }) : super(key: key); - - final TextStyle style; - final double width; - - @override - Widget build(BuildContext context) { - return Container( - alignment: AlignmentDirectional.topEnd, - width: width, - padding: const EdgeInsetsDirectional.only(end: 13), - child: Text('•', style: style), - ); - } -} - -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 int? offset; - final Function(int, bool)? onTap; - - 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: width, - padding: const EdgeInsetsDirectional.only(end: 13), - child: GestureDetector( - onLongPress: () => _onCheckboxClicked(!isChecked), - child: Checkbox( - value: isChecked, - onChanged: _onCheckboxClicked, - ), - ), - ); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/text_block.dart'; diff --git a/lib/widgets/text_line.dart b/lib/widgets/text_line.dart index ef80a024..7c8dcc80 100644 --- a/lib/widgets/text_line.dart +++ b/lib/widgets/text_line.dart @@ -1,892 +1,3 @@ -import 'dart:math' as math; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:tuple/tuple.dart'; - -import '../models/documents/attribute.dart'; -import '../models/documents/nodes/container.dart' as container; -import '../models/documents/nodes/leaf.dart' as leaf; -import '../models/documents/nodes/leaf.dart'; -import '../models/documents/nodes/line.dart'; -import '../models/documents/nodes/node.dart'; -import '../utils/color.dart'; -import 'box.dart'; -import 'cursor.dart'; -import 'default_styles.dart'; -import 'delegate.dart'; -import 'proxy.dart'; -import 'text_selection.dart'; - -class TextLine extends StatelessWidget { - const TextLine({ - required this.line, - required this.embedBuilder, - required this.styles, - this.textDirection, - Key? key, - }) : super(key: key); - - final Line line; - final TextDirection? textDirection; - final EmbedBuilder embedBuilder; - final DefaultStyles styles; - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - - if (line.hasEmbed) { - final embed = line.children.single as Embed; - return EmbedProxy(embedBuilder(context, embed)); - } - - final textSpan = _buildTextSpan(context); - final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); - final textAlign = _getTextAlign(); - final child = RichText( - text: textSpan, - textAlign: textAlign, - textDirection: textDirection, - strutStyle: strutStyle, - textScaleFactor: MediaQuery.textScaleFactorOf(context), - ); - return RichTextProxy( - child, - textSpan.style!, - textAlign, - textDirection!, - 1, - Localizations.localeOf(context), - strutStyle, - TextWidthBasis.parent, - null); - } - - TextAlign _getTextAlign() { - final alignment = line.style.attributes[Attribute.align.key]; - if (alignment == Attribute.leftAlignment) { - return TextAlign.left; - } else if (alignment == Attribute.centerAlignment) { - return TextAlign.center; - } else if (alignment == Attribute.rightAlignment) { - return TextAlign.right; - } else if (alignment == Attribute.justifyAlignment) { - return TextAlign.justify; - } - return TextAlign.start; - } - - TextSpan _buildTextSpan(BuildContext context) { - final defaultStyles = styles; - final children = line.children - .map((node) => _getTextSpanFromNode(defaultStyles, node)) - .toList(growable: false); - - var textStyle = const TextStyle(); - - if (line.style.containsKey(Attribute.placeholder.key)) { - textStyle = defaultStyles.placeHolder!.style; - return TextSpan(children: children, style: textStyle); - } - - final header = line.style.attributes[Attribute.header.key]; - final m = { - Attribute.h1: defaultStyles.h1!.style, - Attribute.h2: defaultStyles.h2!.style, - Attribute.h3: defaultStyles.h3!.style, - }; - - textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); - - final block = line.style.getBlockExceptHeader(); - TextStyle? toMerge; - if (block == Attribute.blockQuote) { - toMerge = defaultStyles.quote!.style; - } else if (block == Attribute.codeBlock) { - toMerge = defaultStyles.code!.style; - } else if (block != null) { - toMerge = defaultStyles.lists!.style; - } - - textStyle = textStyle.merge(toMerge); - - return TextSpan(children: children, style: textStyle); - } - - TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { - final textNode = node as leaf.Text; - final style = textNode.style; - var res = const TextStyle(); - final color = textNode.style.attributes[Attribute.color.key]; - - { - Attribute.bold.key: defaultStyles.bold, - Attribute.italic.key: defaultStyles.italic, - Attribute.link.key: defaultStyles.link, - Attribute.underline.key: defaultStyles.underline, - Attribute.strikeThrough.key: defaultStyles.strikeThrough, - }.forEach((k, s) { - if (style.values.any((v) => v.key == k)) { - if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) { - var textColor = defaultStyles.color; - if (color?.value is String) { - textColor = stringToColor(color?.value); - } - res = _merge(res.copyWith(decorationColor: textColor), - s!.copyWith(decorationColor: textColor)); - } else { - res = _merge(res, s!); - } - } - }); - - final font = textNode.style.attributes[Attribute.font.key]; - if (font != null && font.value != null) { - res = res.merge(TextStyle(fontFamily: font.value)); - } - - final size = textNode.style.attributes[Attribute.size.key]; - if (size != null && size.value != null) { - switch (size.value) { - case 'small': - res = res.merge(defaultStyles.sizeSmall); - break; - case 'large': - res = res.merge(defaultStyles.sizeLarge); - break; - case 'huge': - res = res.merge(defaultStyles.sizeHuge); - break; - default: - final fontSize = double.tryParse(size.value); - if (fontSize != null) { - res = res.merge(TextStyle(fontSize: fontSize)); - } else { - throw 'Invalid size ${size.value}'; - } - } - } - - if (color != null && color.value != null) { - var textColor = defaultStyles.color; - if (color.value is String) { - textColor = stringToColor(color.value); - } - if (textColor != null) { - res = res.merge(TextStyle(color: textColor)); - } - } - - final background = textNode.style.attributes[Attribute.background.key]; - if (background != null && background.value != null) { - final backgroundColor = stringToColor(background.value); - res = res.merge(TextStyle(backgroundColor: backgroundColor)); - } - - return TextSpan(text: textNode.value, style: res); - } - - TextStyle _merge(TextStyle a, TextStyle b) { - final decorations = []; - if (a.decoration != null) { - decorations.add(a.decoration); - } - if (b.decoration != null) { - decorations.add(b.decoration); - } - return a.merge(b).apply( - decoration: TextDecoration.combine( - List.castFrom(decorations))); - } -} - -class EditableTextLine extends RenderObjectWidget { - const EditableTextLine( - this.line, - this.leading, - this.body, - this.indentWidth, - this.verticalSpacing, - this.textDirection, - this.textSelection, - this.color, - this.enableInteractiveSelection, - this.hasFocus, - this.devicePixelRatio, - this.cursorCont, - ); - - final Line line; - final Widget? leading; - final Widget body; - final double indentWidth; - final Tuple2 verticalSpacing; - final TextDirection textDirection; - final TextSelection textSelection; - final Color color; - final bool enableInteractiveSelection; - final bool hasFocus; - final double devicePixelRatio; - final CursorCont cursorCont; - - @override - RenderObjectElement createElement() { - return _TextLineElement(this); - } - - @override - RenderObject createRenderObject(BuildContext context) { - return RenderEditableTextLine( - line, - textDirection, - textSelection, - enableInteractiveSelection, - hasFocus, - devicePixelRatio, - _getPadding(), - color, - cursorCont); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderEditableTextLine renderObject) { - renderObject - ..setLine(line) - ..setPadding(_getPadding()) - ..setTextDirection(textDirection) - ..setTextSelection(textSelection) - ..setColor(color) - ..setEnableInteractiveSelection(enableInteractiveSelection) - ..hasFocus = hasFocus - ..setDevicePixelRatio(devicePixelRatio) - ..setCursorCont(cursorCont); - } - - EdgeInsetsGeometry _getPadding() { - return EdgeInsetsDirectional.only( - start: indentWidth, - top: verticalSpacing.item1, - bottom: verticalSpacing.item2); - } -} - -enum TextLineSlot { LEADING, BODY } - -class RenderEditableTextLine extends RenderEditableBox { - RenderEditableTextLine( - this.line, - this.textDirection, - this.textSelection, - this.enableInteractiveSelection, - this.hasFocus, - this.devicePixelRatio, - this.padding, - this.color, - this.cursorCont, - ); - - RenderBox? _leading; - RenderContentProxyBox? _body; - Line line; - TextDirection textDirection; - TextSelection textSelection; - Color color; - bool enableInteractiveSelection; - bool hasFocus = false; - double devicePixelRatio; - EdgeInsetsGeometry padding; - CursorCont cursorCont; - EdgeInsets? _resolvedPadding; - bool? _containsCursor; - List? _selectedRects; - Rect? _caretPrototype; - final Map children = {}; - - Iterable get _children sync* { - if (_leading != null) { - yield _leading!; - } - if (_body != null) { - yield _body!; - } - } - - void setCursorCont(CursorCont c) { - if (cursorCont == c) { - return; - } - cursorCont = c; - markNeedsLayout(); - } - - void setDevicePixelRatio(double d) { - if (devicePixelRatio == d) { - return; - } - devicePixelRatio = d; - markNeedsLayout(); - } - - void setEnableInteractiveSelection(bool val) { - if (enableInteractiveSelection == val) { - return; - } - - markNeedsLayout(); - markNeedsSemanticsUpdate(); - } - - void setColor(Color c) { - if (color == c) { - return; - } - - color = c; - if (containsTextSelection()) { - markNeedsPaint(); - } - } - - void setTextSelection(TextSelection t) { - if (textSelection == t) { - return; - } - - final containsSelection = containsTextSelection(); - if (attached && containsCursor()) { - cursorCont.removeListener(markNeedsLayout); - cursorCont.color.removeListener(markNeedsPaint); - } - - textSelection = t; - _selectedRects = null; - _containsCursor = null; - if (attached && containsCursor()) { - cursorCont.addListener(markNeedsLayout); - cursorCont.color.addListener(markNeedsPaint); - } - - if (containsSelection || containsTextSelection()) { - markNeedsPaint(); - } - } - - void setTextDirection(TextDirection t) { - if (textDirection == t) { - return; - } - textDirection = t; - _resolvedPadding = null; - markNeedsLayout(); - } - - void setLine(Line l) { - if (line == l) { - return; - } - line = l; - _containsCursor = null; - markNeedsLayout(); - } - - void setPadding(EdgeInsetsGeometry p) { - assert(p.isNonNegative); - if (padding == p) { - return; - } - padding = p; - _resolvedPadding = null; - markNeedsLayout(); - } - - void setLeading(RenderBox? l) { - _leading = _updateChild(_leading, l, TextLineSlot.LEADING); - } - - void setBody(RenderContentProxyBox? b) { - _body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?; - } - - bool containsTextSelection() { - return line.documentOffset <= textSelection.end && - textSelection.start <= line.documentOffset + line.length - 1; - } - - bool containsCursor() { - return _containsCursor ??= textSelection.isCollapsed && - line.containsOffset(textSelection.baseOffset); - } - - RenderBox? _updateChild( - RenderBox? old, RenderBox? newChild, TextLineSlot slot) { - if (old != null) { - dropChild(old); - children.remove(slot); - } - if (newChild != null) { - children[slot] = newChild; - adoptChild(newChild); - } - return newChild; - } - - List _getBoxes(TextSelection textSelection) { - final parentData = _body!.parentData as BoxParentData?; - return _body!.getBoxesForSelection(textSelection).map((box) { - return TextBox.fromLTRBD( - box.left + parentData!.offset.dx, - box.top + parentData.offset.dy, - box.right + parentData.offset.dx, - box.bottom + parentData.offset.dy, - box.direction, - ); - }).toList(growable: false); - } - - void _resolvePadding() { - if (_resolvedPadding != null) { - return; - } - _resolvedPadding = padding.resolve(textDirection); - assert(_resolvedPadding!.isNonNegative); - } - - @override - TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) { - return _getEndpointForSelection(textSelection, true); - } - - @override - TextSelectionPoint getExtentEndpointForSelection( - TextSelection textSelection) { - return _getEndpointForSelection(textSelection, false); - } - - TextSelectionPoint _getEndpointForSelection( - TextSelection textSelection, bool first) { - if (textSelection.isCollapsed) { - return TextSelectionPoint( - Offset(0, preferredLineHeight(textSelection.extent)) + - getOffsetForCaret(textSelection.extent), - null); - } - final boxes = _getBoxes(textSelection); - assert(boxes.isNotEmpty); - final targetBox = first ? boxes.first : boxes.last; - return TextSelectionPoint( - Offset(first ? targetBox.start : targetBox.end, targetBox.bottom), - targetBox.direction); - } - - @override - TextRange getLineBoundary(TextPosition position) { - final lineDy = getOffsetForCaret(position) - .translate(0, 0.5 * preferredLineHeight(position)) - .dy; - final lineBoxes = - _getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1)) - .where((element) => element.top < lineDy && element.bottom > lineDy) - .toList(growable: false); - return TextRange( - start: - getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset, - end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset); - } - - @override - Offset getOffsetForCaret(TextPosition position) { - return _body!.getOffsetForCaret(position, _caretPrototype) + - (_body!.parentData as BoxParentData).offset; - } - - @override - TextPosition? getPositionAbove(TextPosition position) { - return _getPosition(position, -0.5); - } - - @override - TextPosition? getPositionBelow(TextPosition position) { - return _getPosition(position, 1.5); - } - - TextPosition? _getPosition(TextPosition textPosition, double dyScale) { - assert(textPosition.offset < line.length); - final offset = getOffsetForCaret(textPosition) - .translate(0, dyScale * preferredLineHeight(textPosition)); - if (_body!.size - .contains(offset - (_body!.parentData as BoxParentData).offset)) { - return getPositionForOffset(offset); - } - return null; - } - - @override - TextPosition getPositionForOffset(Offset offset) { - return _body!.getPositionForOffset( - offset - (_body!.parentData as BoxParentData).offset); - } - - @override - TextRange getWordBoundary(TextPosition position) { - return _body!.getWordBoundary(position); - } - - @override - double preferredLineHeight(TextPosition position) { - return _body!.getPreferredLineHeight(); - } - - @override - container.Container getContainer() { - return line; - } - - double get cursorWidth => cursorCont.style.width; - - double get cursorHeight => - cursorCont.style.height ?? - preferredLineHeight(const TextPosition(offset: 0)); - - void _computeCaretPrototype() { - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - _caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2); - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - _caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0); - break; - default: - throw 'Invalid platform'; - } - } - - @override - void attach(covariant PipelineOwner owner) { - super.attach(owner); - for (final child in _children) { - child.attach(owner); - } - if (containsCursor()) { - cursorCont.addListener(markNeedsLayout); - cursorCont.cursorColor.addListener(markNeedsPaint); - } - } - - @override - void detach() { - super.detach(); - for (final child in _children) { - child.detach(); - } - if (containsCursor()) { - cursorCont.removeListener(markNeedsLayout); - cursorCont.cursorColor.removeListener(markNeedsPaint); - } - } - - @override - void redepthChildren() { - _children.forEach(redepthChild); - } - - @override - void visitChildren(RenderObjectVisitor visitor) { - _children.forEach(visitor); - } - - @override - List debugDescribeChildren() { - final value = []; - void add(RenderBox? child, String name) { - if (child != null) { - value.add(child.toDiagnosticsNode(name: name)); - } - } - - add(_leading, 'leading'); - add(_body, 'body'); - return value; - } - - @override - bool get sizedByParent => false; - - @override - double computeMinIntrinsicWidth(double height) { - _resolvePadding(); - final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; - final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; - final leadingWidth = _leading == null - ? 0 - : _leading!.getMinIntrinsicWidth(height - verticalPadding) as int; - final bodyWidth = _body == null - ? 0 - : _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) - as int; - return horizontalPadding + leadingWidth + bodyWidth; - } - - @override - double computeMaxIntrinsicWidth(double height) { - _resolvePadding(); - final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; - final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; - final leadingWidth = _leading == null - ? 0 - : _leading!.getMaxIntrinsicWidth(height - verticalPadding) as int; - final bodyWidth = _body == null - ? 0 - : _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) - as int; - return horizontalPadding + leadingWidth + bodyWidth; - } - - @override - double computeMinIntrinsicHeight(double width) { - _resolvePadding(); - final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; - final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; - if (_body != null) { - return _body! - .getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) + - verticalPadding; - } - return verticalPadding; - } - - @override - double computeMaxIntrinsicHeight(double width) { - _resolvePadding(); - final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; - final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; - if (_body != null) { - return _body! - .getMaxIntrinsicHeight(math.max(0, width - horizontalPadding)) + - verticalPadding; - } - return verticalPadding; - } - - @override - double computeDistanceToActualBaseline(TextBaseline baseline) { - _resolvePadding(); - return _body!.getDistanceToActualBaseline(baseline)! + - _resolvedPadding!.top; - } - - @override - void performLayout() { - final constraints = this.constraints; - _selectedRects = null; - - _resolvePadding(); - assert(_resolvedPadding != null); - - if (_body == null && _leading == null) { - size = constraints.constrain(Size( - _resolvedPadding!.left + _resolvedPadding!.right, - _resolvedPadding!.top + _resolvedPadding!.bottom, - )); - return; - } - final innerConstraints = constraints.deflate(_resolvedPadding!); - - final indentWidth = textDirection == TextDirection.ltr - ? _resolvedPadding!.left - : _resolvedPadding!.right; - - _body!.layout(innerConstraints, parentUsesSize: true); - (_body!.parentData as BoxParentData).offset = - Offset(_resolvedPadding!.left, _resolvedPadding!.top); - - if (_leading != null) { - final leadingConstraints = innerConstraints.copyWith( - minWidth: indentWidth, - maxWidth: indentWidth, - maxHeight: _body!.size.height); - _leading!.layout(leadingConstraints, parentUsesSize: true); - (_leading!.parentData as BoxParentData).offset = - Offset(0, _resolvedPadding!.top); - } - - size = constraints.constrain(Size( - _resolvedPadding!.left + _body!.size.width + _resolvedPadding!.right, - _resolvedPadding!.top + _body!.size.height + _resolvedPadding!.bottom, - )); - - _computeCaretPrototype(); - } - - CursorPainter get _cursorPainter => CursorPainter( - _body, - cursorCont.style, - _caretPrototype, - cursorCont.cursorColor.value, - devicePixelRatio, - ); - - @override - void paint(PaintingContext context, Offset offset) { - if (_leading != null) { - final parentData = _leading!.parentData as BoxParentData; - final effectiveOffset = offset + parentData.offset; - context.paintChild(_leading!, effectiveOffset); - } - - if (_body != null) { - final parentData = _body!.parentData as BoxParentData; - final effectiveOffset = offset + parentData.offset; - if (enableInteractiveSelection && - line.documentOffset <= textSelection.end && - textSelection.start <= line.documentOffset + line.length - 1) { - final local = localSelection(line, textSelection, false); - _selectedRects ??= _body!.getBoxesForSelection( - local, - ); - _paintSelection(context, effectiveOffset); - } - - if (hasFocus && - cursorCont.show.value && - containsCursor() && - !cursorCont.style.paintAboveText) { - _paintCursor(context, effectiveOffset); - } - - context.paintChild(_body!, effectiveOffset); - - if (hasFocus && - cursorCont.show.value && - containsCursor() && - cursorCont.style.paintAboveText) { - _paintCursor(context, effectiveOffset); - } - } - } - - void _paintSelection(PaintingContext context, Offset effectiveOffset) { - assert(_selectedRects != null); - final paint = Paint()..color = color; - for (final box in _selectedRects!) { - context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint); - } - } - - void _paintCursor(PaintingContext context, Offset effectiveOffset) { - final position = TextPosition( - offset: textSelection.extentOffset - line.documentOffset, - affinity: textSelection.base.affinity, - ); - _cursorPainter.paint(context.canvas, effectiveOffset, position); - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return _children.first.hitTest(result, position: position); - } -} - -class _TextLineElement extends RenderObjectElement { - _TextLineElement(EditableTextLine line) : super(line); - - final Map _slotToChildren = {}; - - @override - EditableTextLine get widget => super.widget as EditableTextLine; - - @override - RenderEditableTextLine get renderObject => - super.renderObject as RenderEditableTextLine; - - @override - void visitChildren(ElementVisitor visitor) { - _slotToChildren.values.forEach(visitor); - } - - @override - void forgetChild(Element child) { - assert(_slotToChildren.containsValue(child)); - assert(child.slot is TextLineSlot); - assert(_slotToChildren.containsKey(child.slot)); - _slotToChildren.remove(child.slot); - super.forgetChild(child); - } - - @override - void mount(Element? parent, dynamic newSlot) { - super.mount(parent, newSlot); - _mountChild(widget.leading, TextLineSlot.LEADING); - _mountChild(widget.body, TextLineSlot.BODY); - } - - @override - void update(EditableTextLine newWidget) { - super.update(newWidget); - assert(widget == newWidget); - _updateChild(widget.leading, TextLineSlot.LEADING); - _updateChild(widget.body, TextLineSlot.BODY); - } - - @override - void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) { - // assert(child is RenderBox); - _updateRenderObject(child, slot); - assert(renderObject.children.keys.contains(slot)); - } - - @override - void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) { - assert(child is RenderBox); - assert(renderObject.children[slot!] == child); - _updateRenderObject(null, slot); - assert(!renderObject.children.keys.contains(slot)); - } - - @override - void moveRenderObjectChild( - RenderObject child, dynamic oldSlot, dynamic newSlot) { - throw UnimplementedError(); - } - - void _mountChild(Widget? widget, TextLineSlot slot) { - final oldChild = _slotToChildren[slot]; - final newChild = updateChild(oldChild, widget, slot); - if (oldChild != null) { - _slotToChildren.remove(slot); - } - if (newChild != null) { - _slotToChildren[slot] = newChild; - } - } - - void _updateRenderObject(RenderBox? child, TextLineSlot? slot) { - switch (slot) { - case TextLineSlot.LEADING: - renderObject.setLeading(child); - break; - case TextLineSlot.BODY: - renderObject.setBody(child as RenderContentProxyBox?); - break; - default: - throw UnimplementedError(); - } - } - - void _updateChild(Widget? widget, TextLineSlot slot) { - final oldChild = _slotToChildren[slot]; - final newChild = updateChild(oldChild, widget, slot); - if (oldChild != null) { - _slotToChildren.remove(slot); - } - if (newChild != null) { - _slotToChildren[slot] = newChild; - } - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/text_line.dart'; diff --git a/lib/widgets/text_selection.dart b/lib/widgets/text_selection.dart index a8748de1..b35db3ea 100644 --- a/lib/widgets/text_selection.dart +++ b/lib/widgets/text_selection.dart @@ -1,726 +1,3 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; - -import '../models/documents/nodes/node.dart'; -import 'editor.dart'; - -TextSelection localSelection(Node node, TextSelection selection, fromParent) { - final base = fromParent ? node.offset : node.documentOffset; - assert(base <= selection.end && selection.start <= base + node.length - 1); - - final offset = fromParent ? node.offset : node.documentOffset; - return selection.copyWith( - baseOffset: math.max(selection.start - offset, 0), - extentOffset: math.min(selection.end - offset, node.length - 1)); -} - -enum _TextSelectionHandlePosition { START, END } - -class EditorTextSelectionOverlay { - 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, - ) { - final overlay = Overlay.of(context, rootOverlay: true)!; - - _toolbarController = AnimationController( - duration: const Duration(milliseconds: 150), vsync: overlay); - } - - 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; - late AnimationController _toolbarController; - List? _handles; - OverlayEntry? toolbar; - - TextSelection get _selection => value.selection; - - Animation get _toolbarOpacity => _toolbarController.view; - - void setHandlesVisible(bool visible) { - if (handlesVisible == visible) { - return; - } - handlesVisible = visible; - if (SchedulerBinding.instance!.schedulerPhase == - SchedulerPhase.persistentCallbacks) { - SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); - } else { - markNeedsBuild(); - } - } - - void hideHandles() { - if (_handles == null) { - return; - } - _handles![0].remove(); - _handles![1].remove(); - _handles = null; - } - - void hideToolbar() { - assert(toolbar != null); - _toolbarController.stop(); - toolbar!.remove(); - toolbar = null; - } - - void showToolbar() { - assert(toolbar == null); - toolbar = OverlayEntry(builder: _buildToolbar); - Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! - .insert(toolbar!); - _toolbarController.forward(from: 0); - } - - Widget _buildHandle( - BuildContext context, _TextSelectionHandlePosition position) { - if (_selection.isCollapsed && - position == _TextSelectionHandlePosition.END) { - return Container(); - } - return Visibility( - visible: handlesVisible, - child: _TextSelectionHandleOverlay( - onSelectionHandleChanged: (newSelection) { - _handleSelectionHandleChanged(newSelection, position); - }, - onSelectionHandleTapped: onSelectionHandleTapped, - startHandleLayerLink: startHandleLayerLink, - endHandleLayerLink: endHandleLayerLink, - renderObject: renderObject, - selection: _selection, - selectionControls: selectionCtrls, - position: position, - dragStartBehavior: dragStartBehavior, - )); - } - - void update(TextEditingValue newValue) { - if (value == newValue) { - return; - } - value = newValue; - if (SchedulerBinding.instance!.schedulerPhase == - SchedulerPhase.persistentCallbacks) { - SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); - } else { - markNeedsBuild(); - } - } - - void _handleSelectionHandleChanged( - TextSelection? newSelection, _TextSelectionHandlePosition position) { - TextPosition textPosition; - switch (position) { - case _TextSelectionHandlePosition.START: - textPosition = newSelection != null - ? newSelection.base - : const TextPosition(offset: 0); - break; - case _TextSelectionHandlePosition.END: - textPosition = newSelection != null - ? newSelection.extent - : const TextPosition(offset: 0); - break; - default: - throw 'Invalid position'; - } - selectionDelegate - ..textEditingValue = - value.copyWith(selection: newSelection, composing: TextRange.empty) - ..bringIntoView(textPosition); - } - - Widget _buildToolbar(BuildContext context) { - final endpoints = renderObject!.getEndpointsForSelection(_selection); - - final editingRegion = Rect.fromPoints( - renderObject!.localToGlobal(Offset.zero), - renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)), - ); - - final baseLineHeight = renderObject!.preferredLineHeight(_selection.base); - final extentLineHeight = - renderObject!.preferredLineHeight(_selection.extent); - final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); - final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > - smallestLineHeight / 2; - - final midX = isMultiline - ? editingRegion.width / 2 - : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; - - final midpoint = Offset( - midX, - endpoints[0].point.dy - baseLineHeight, - ); - - return FadeTransition( - opacity: _toolbarOpacity, - child: CompositedTransformFollower( - link: toolbarLayerLink, - showWhenUnlinked: false, - offset: -editingRegion.topLeft, - child: selectionCtrls.buildToolbar( - context, - editingRegion, - baseLineHeight, - midpoint, - endpoints, - selectionDelegate, - clipboardStatus, - const Offset(0, 0)), - ), - ); - } - - void markNeedsBuild([Duration? duration]) { - if (_handles != null) { - _handles![0].markNeedsBuild(); - _handles![1].markNeedsBuild(); - } - toolbar?.markNeedsBuild(); - } - - void hide() { - if (_handles != null) { - _handles![0].remove(); - _handles![1].remove(); - _handles = null; - } - if (toolbar != null) { - hideToolbar(); - } - } - - void dispose() { - hide(); - _toolbarController.dispose(); - } - - void showHandles() { - assert(_handles == null); - _handles = [ - OverlayEntry( - builder: (context) => - _buildHandle(context, _TextSelectionHandlePosition.START)), - OverlayEntry( - builder: (context) => - _buildHandle(context, _TextSelectionHandlePosition.END)), - ]; - - Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! - .insertAll(_handles!); - } -} - -class _TextSelectionHandleOverlay extends StatefulWidget { - const _TextSelectionHandleOverlay({ - required this.selection, - required this.position, - required this.startHandleLayerLink, - required this.endHandleLayerLink, - required this.renderObject, - required this.onSelectionHandleChanged, - required this.onSelectionHandleTapped, - required this.selectionControls, - this.dragStartBehavior = DragStartBehavior.start, - Key? key, - }) : super(key: key); - - final TextSelection selection; - final _TextSelectionHandlePosition position; - final LayerLink startHandleLayerLink; - final LayerLink endHandleLayerLink; - final RenderEditor? renderObject; - final ValueChanged onSelectionHandleChanged; - final VoidCallback? onSelectionHandleTapped; - final TextSelectionControls selectionControls; - final DragStartBehavior dragStartBehavior; - - @override - _TextSelectionHandleOverlayState createState() => - _TextSelectionHandleOverlayState(); - - ValueListenable? get _visibility { - switch (position) { - case _TextSelectionHandlePosition.START: - return renderObject!.selectionStartInViewport; - case _TextSelectionHandlePosition.END: - return renderObject!.selectionEndInViewport; - } - } -} - -class _TextSelectionHandleOverlayState - extends State<_TextSelectionHandleOverlay> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - Animation get _opacity => _controller.view; - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - duration: const Duration(milliseconds: 150), vsync: this); - - _handleVisibilityChanged(); - widget._visibility!.addListener(_handleVisibilityChanged); - } - - void _handleVisibilityChanged() { - if (widget._visibility!.value) { - _controller.forward(); - } else { - _controller.reverse(); - } - } - - @override - void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { - super.didUpdateWidget(oldWidget); - oldWidget._visibility!.removeListener(_handleVisibilityChanged); - _handleVisibilityChanged(); - widget._visibility!.addListener(_handleVisibilityChanged); - } - - @override - void dispose() { - widget._visibility!.removeListener(_handleVisibilityChanged); - _controller.dispose(); - super.dispose(); - } - - void _handleDragStart(DragStartDetails details) {} - - void _handleDragUpdate(DragUpdateDetails details) { - final position = - widget.renderObject!.getPositionForOffset(details.globalPosition); - if (widget.selection.isCollapsed) { - widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); - return; - } - - final isNormalized = - widget.selection.extentOffset >= widget.selection.baseOffset; - TextSelection? newSelection; - switch (widget.position) { - case _TextSelectionHandlePosition.START: - newSelection = TextSelection( - baseOffset: - isNormalized ? position.offset : widget.selection.baseOffset, - extentOffset: - isNormalized ? widget.selection.extentOffset : position.offset, - ); - break; - case _TextSelectionHandlePosition.END: - newSelection = TextSelection( - baseOffset: - isNormalized ? widget.selection.baseOffset : position.offset, - extentOffset: - isNormalized ? position.offset : widget.selection.extentOffset, - ); - break; - } - - widget.onSelectionHandleChanged(newSelection); - } - - void _handleTap() { - if (widget.onSelectionHandleTapped != null) { - widget.onSelectionHandleTapped!(); - } - } - - @override - Widget build(BuildContext context) { - late LayerLink layerLink; - TextSelectionHandleType? type; - - switch (widget.position) { - case _TextSelectionHandlePosition.START: - layerLink = widget.startHandleLayerLink; - type = _chooseType( - widget.renderObject!.textDirection, - TextSelectionHandleType.left, - TextSelectionHandleType.right, - ); - break; - case _TextSelectionHandlePosition.END: - assert(!widget.selection.isCollapsed); - layerLink = widget.endHandleLayerLink; - type = _chooseType( - widget.renderObject!.textDirection, - TextSelectionHandleType.right, - TextSelectionHandleType.left, - ); - break; - } - - final textPosition = widget.position == _TextSelectionHandlePosition.START - ? widget.selection.base - : widget.selection.extent; - final lineHeight = widget.renderObject!.preferredLineHeight(textPosition); - final handleAnchor = - widget.selectionControls.getHandleAnchor(type!, lineHeight); - final handleSize = widget.selectionControls.getHandleSize(lineHeight); - - final handleRect = Rect.fromLTWH( - -handleAnchor.dx, - -handleAnchor.dy, - handleSize.width, - handleSize.height, - ); - - final interactiveRect = handleRect.expandToInclude( - Rect.fromCircle( - center: handleRect.center, radius: kMinInteractiveDimension / 2), - ); - final padding = RelativeRect.fromLTRB( - math.max((interactiveRect.width - handleRect.width) / 2, 0), - math.max((interactiveRect.height - handleRect.height) / 2, 0), - math.max((interactiveRect.width - handleRect.width) / 2, 0), - math.max((interactiveRect.height - handleRect.height) / 2, 0), - ); - - return CompositedTransformFollower( - link: layerLink, - offset: interactiveRect.topLeft, - showWhenUnlinked: false, - child: FadeTransition( - opacity: _opacity, - child: Container( - alignment: Alignment.topLeft, - width: interactiveRect.width, - height: interactiveRect.height, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - dragStartBehavior: widget.dragStartBehavior, - onPanStart: _handleDragStart, - onPanUpdate: _handleDragUpdate, - onTap: _handleTap, - child: Padding( - padding: EdgeInsets.only( - left: padding.left, - top: padding.top, - right: padding.right, - bottom: padding.bottom, - ), - child: widget.selectionControls.buildHandle( - context, - type, - lineHeight, - ), - ), - ), - ), - ), - ); - } - - TextSelectionHandleType? _chooseType( - TextDirection textDirection, - TextSelectionHandleType ltrType, - TextSelectionHandleType rtlType, - ) { - if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; - - switch (textDirection) { - case TextDirection.ltr: - return ltrType; - case TextDirection.rtl: - return rtlType; - } - } -} - -class EditorTextSelectionGestureDetector extends StatefulWidget { - const EditorTextSelectionGestureDetector({ - required this.child, - this.onTapDown, - this.onForcePressStart, - this.onForcePressEnd, - this.onSingleTapUp, - this.onSingleTapCancel, - this.onSingleLongTapStart, - this.onSingleLongTapMoveUpdate, - this.onSingleLongTapEnd, - this.onDoubleTapDown, - this.onDragSelectionStart, - this.onDragSelectionUpdate, - this.onDragSelectionEnd, - this.behavior, - Key? key, - }) : super(key: key); - - final GestureTapDownCallback? onTapDown; - - final GestureForcePressStartCallback? onForcePressStart; - - final GestureForcePressEndCallback? onForcePressEnd; - - final GestureTapUpCallback? onSingleTapUp; - - final GestureTapCancelCallback? onSingleTapCancel; - - final GestureLongPressStartCallback? onSingleLongTapStart; - - final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; - - final GestureLongPressEndCallback? onSingleLongTapEnd; - - final GestureTapDownCallback? onDoubleTapDown; - - final GestureDragStartCallback? onDragSelectionStart; - - final DragSelectionUpdateCallback? onDragSelectionUpdate; - - final GestureDragEndCallback? onDragSelectionEnd; - - final HitTestBehavior? behavior; - - final Widget child; - - @override - State createState() => - _EditorTextSelectionGestureDetectorState(); -} - -class _EditorTextSelectionGestureDetectorState - extends State { - Timer? _doubleTapTimer; - Offset? _lastTapOffset; - bool _isDoubleTap = false; - - @override - void dispose() { - _doubleTapTimer?.cancel(); - _dragUpdateThrottleTimer?.cancel(); - super.dispose(); - } - - void _handleTapDown(TapDownDetails details) { - // renderObject.resetTapDownStatus(); - if (widget.onTapDown != null) { - widget.onTapDown!(details); - } - if (_doubleTapTimer != null && - _isWithinDoubleTapTolerance(details.globalPosition)) { - if (widget.onDoubleTapDown != null) { - widget.onDoubleTapDown!(details); - } - - _doubleTapTimer!.cancel(); - _doubleTapTimeout(); - _isDoubleTap = true; - } - } - - void _handleTapUp(TapUpDetails details) { - if (!_isDoubleTap) { - if (widget.onSingleTapUp != null) { - widget.onSingleTapUp!(details); - } - _lastTapOffset = details.globalPosition; - _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); - } - _isDoubleTap = false; - } - - void _handleTapCancel() { - if (widget.onSingleTapCancel != null) { - widget.onSingleTapCancel!(); - } - } - - DragStartDetails? _lastDragStartDetails; - DragUpdateDetails? _lastDragUpdateDetails; - Timer? _dragUpdateThrottleTimer; - - void _handleDragStart(DragStartDetails details) { - assert(_lastDragStartDetails == null); - _lastDragStartDetails = details; - if (widget.onDragSelectionStart != null) { - widget.onDragSelectionStart!(details); - } - } - - void _handleDragUpdate(DragUpdateDetails details) { - _lastDragUpdateDetails = details; - _dragUpdateThrottleTimer ??= - Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled); - } - - void _handleDragUpdateThrottled() { - assert(_lastDragStartDetails != null); - assert(_lastDragUpdateDetails != null); - if (widget.onDragSelectionUpdate != null) { - widget.onDragSelectionUpdate!( - _lastDragStartDetails!, _lastDragUpdateDetails!); - } - _dragUpdateThrottleTimer = null; - _lastDragUpdateDetails = null; - } - - void _handleDragEnd(DragEndDetails details) { - assert(_lastDragStartDetails != null); - if (_dragUpdateThrottleTimer != null) { - _dragUpdateThrottleTimer!.cancel(); - _handleDragUpdateThrottled(); - } - if (widget.onDragSelectionEnd != null) { - widget.onDragSelectionEnd!(details); - } - _dragUpdateThrottleTimer = null; - _lastDragStartDetails = null; - _lastDragUpdateDetails = null; - } - - void _forcePressStarted(ForcePressDetails details) { - _doubleTapTimer?.cancel(); - _doubleTapTimer = null; - if (widget.onForcePressStart != null) { - widget.onForcePressStart!(details); - } - } - - void _forcePressEnded(ForcePressDetails details) { - if (widget.onForcePressEnd != null) { - widget.onForcePressEnd!(details); - } - } - - void _handleLongPressStart(LongPressStartDetails details) { - if (!_isDoubleTap && widget.onSingleLongTapStart != null) { - widget.onSingleLongTapStart!(details); - } - } - - void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { - widget.onSingleLongTapMoveUpdate!(details); - } - } - - void _handleLongPressEnd(LongPressEndDetails details) { - if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { - widget.onSingleLongTapEnd!(details); - } - _isDoubleTap = false; - } - - void _doubleTapTimeout() { - _doubleTapTimer = null; - _lastTapOffset = null; - } - - bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { - if (_lastTapOffset == null) { - return false; - } - - return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop; - } - - @override - Widget build(BuildContext context) { - final gestures = {}; - - gestures[TapGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (instance) { - instance - ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp - ..onTapCancel = _handleTapCancel; - }, - ); - - if (widget.onSingleLongTapStart != null || - widget.onSingleLongTapMoveUpdate != null || - widget.onSingleLongTapEnd != null) { - gestures[LongPressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer( - debugOwner: this, kind: PointerDeviceKind.touch), - (instance) { - instance - ..onLongPressStart = _handleLongPressStart - ..onLongPressMoveUpdate = _handleLongPressMoveUpdate - ..onLongPressEnd = _handleLongPressEnd; - }, - ); - } - - if (widget.onDragSelectionStart != null || - widget.onDragSelectionUpdate != null || - widget.onDragSelectionEnd != null) { - gestures[HorizontalDragGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => HorizontalDragGestureRecognizer( - debugOwner: this, kind: PointerDeviceKind.mouse), - (instance) { - instance - ..dragStartBehavior = DragStartBehavior.down - ..onStart = _handleDragStart - ..onUpdate = _handleDragUpdate - ..onEnd = _handleDragEnd; - }, - ); - } - - if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { - gestures[ForcePressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => ForcePressGestureRecognizer(debugOwner: this), - (instance) { - instance - ..onStart = - widget.onForcePressStart != null ? _forcePressStarted : null - ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; - }, - ); - } - - return RawGestureDetector( - gestures: gestures, - excludeFromSemantics: true, - behavior: widget.behavior, - child: widget.child, - ); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/text_selection.dart'; diff --git a/lib/widgets/toolbar.dart b/lib/widgets/toolbar.dart index c5fe2bab..c47f1353 100644 --- a/lib/widgets/toolbar.dart +++ b/lib/widgets/toolbar.dart @@ -1,1294 +1,3 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:filesystem_picker/filesystem_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_colorpicker/flutter_colorpicker.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../models/documents/attribute.dart'; -import '../models/documents/nodes/embed.dart'; -import '../models/documents/style.dart'; -import '../utils/color.dart'; -import 'controller.dart'; - -typedef OnImagePickCallback = Future Function(File file); -typedef ImagePickImpl = Future Function(ImageSource source); - -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) { - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: controller.iconSize * 1.77, - icon: Icon( - icon, - size: controller.iconSize, - color: Theme.of(context).iconTheme.color, - ), - fillColor: fillColor ?? Theme.of(context).canvasColor, - onPressed: () { - final index = controller.selection.baseOffset; - final length = controller.selection.extentOffset - index; - controller.replaceText(index, length, BlockEmbed.horizontalRule, null); - }, - ); - } -} - -class LinkStyleButton extends StatefulWidget { - const LinkStyleButton({ - required this.controller, - this.icon, - Key? key, - }) : super(key: key); - - final QuillController controller; - final IconData? icon; - - @override - _LinkStyleButtonState createState() => _LinkStyleButtonState(); -} - -class _LinkStyleButtonState extends State { - void _didChangeSelection() { - setState(() {}); - } - - @override - void initState() { - super.initState(); - widget.controller.addListener(_didChangeSelection); - } - - @override - void didUpdateWidget(covariant LinkStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeSelection); - widget.controller.addListener(_didChangeSelection); - } - } - - @override - void dispose() { - super.dispose(); - widget.controller.removeListener(_didChangeSelection); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEnabled = !widget.controller.selection.isCollapsed; - final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon( - widget.icon ?? Icons.link, - size: widget.controller.iconSize, - color: isEnabled ? theme.iconTheme.color : theme.disabledColor, - ), - fillColor: Theme.of(context).canvasColor, - onPressed: pressedHandler, - ); - } - - void _openLinkDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) { - return const _LinkDialog(); - }, - ).then(_linkSubmitted); - } - - void _linkSubmitted(String? value) { - if (value == null || value.isEmpty) { - return; - } - widget.controller.formatSelection(LinkAttribute(value)); - } -} - -class _LinkDialog extends StatefulWidget { - const _LinkDialog({Key? key}) : super(key: key); - - @override - _LinkDialogState createState() => _LinkDialogState(); -} - -class _LinkDialogState extends State<_LinkDialog> { - String _link = ''; - - @override - Widget build(BuildContext context) { - return AlertDialog( - content: TextField( - decoration: const InputDecoration(labelText: 'Paste a link'), - autofocus: true, - onChanged: _linkChanged, - ), - actions: [ - TextButton( - onPressed: _link.isNotEmpty ? _applyLink : null, - child: const Text('Apply'), - ), - ], - ); - } - - void _linkChanged(String value) { - setState(() { - _link = value; - }); - } - - void _applyLink() { - Navigator.pop(context, _link); - } -} - -typedef ToggleStyleButtonBuilder = Widget Function( - BuildContext context, - Attribute attribute, - IconData icon, - Color? fillColor, - bool? isToggled, - VoidCallback? onPressed, -); - -class ToggleStyleButton extends StatefulWidget { - const ToggleStyleButton({ - required this.attribute, - required this.icon, - required this.controller, - this.fillColor, - this.childBuilder = defaultToggleStyleButtonBuilder, - Key? key, - }) : super(key: key); - - final Attribute attribute; - - final IconData icon; - - final Color? fillColor; - - final QuillController controller; - - final ToggleStyleButtonBuilder childBuilder; - - @override - _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); -} - -class _ToggleStyleButtonState extends State { - bool? _isToggled; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggled = - _getIsToggled(widget.controller.getSelectionStyle().attributes); - }); - } - - @override - void initState() { - super.initState(); - _isToggled = _getIsToggled(_selectionStyle.attributes); - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { - final attribute = attrs[widget.attribute.key]; - if (attribute == null) { - return false; - } - return attribute.value == widget.attribute.value; - } - return attrs.containsKey(widget.attribute.key); - } - - @override - void didUpdateWidget(covariant ToggleStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggled = _getIsToggled(_selectionStyle.attributes); - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isInCodeBlock = - _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); - final isEnabled = - !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; - return widget.childBuilder(context, widget.attribute, widget.icon, - widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); - } - - void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! - ? Attribute.clone(widget.attribute, null) - : widget.attribute); - } -} - -class ToggleCheckListButton extends StatefulWidget { - const ToggleCheckListButton({ - 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; - - final Attribute attribute; - - @override - _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); -} - -class _ToggleCheckListButtonState extends State { - bool? _isToggled; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggled = - _getIsToggled(widget.controller.getSelectionStyle().attributes); - }); - } - - @override - void initState() { - super.initState(); - _isToggled = _getIsToggled(_selectionStyle.attributes); - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { - final attribute = attrs[widget.attribute.key]; - if (attribute == null) { - return false; - } - return attribute.value == widget.attribute.value || - attribute.value == Attribute.checked.value; - } - return attrs.containsKey(widget.attribute.key); - } - - @override - void didUpdateWidget(covariant ToggleCheckListButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggled = _getIsToggled(_selectionStyle.attributes); - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isInCodeBlock = - _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); - final isEnabled = - !isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; - return widget.childBuilder(context, Attribute.unchecked, widget.icon, - widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); - } - - void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! - ? Attribute.clone(Attribute.unchecked, null) - : Attribute.unchecked); - } -} - -Widget defaultToggleStyleButtonBuilder( - BuildContext context, - Attribute attribute, - IconData icon, - Color? fillColor, - bool? isToggled, - VoidCallback? onPressed, -) { - final theme = Theme.of(context); - final isEnabled = onPressed != null; - final iconColor = isEnabled - ? isToggled == true - ? theme.primaryIconTheme.color - : theme.iconTheme.color - : theme.disabledColor; - final fill = isToggled == true - ? theme.toggleableActiveColor - : fillColor ?? theme.canvasColor; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: 18 * 1.77, - icon: Icon(icon, size: 18, color: iconColor), - fillColor: fill, - onPressed: onPressed, - ); -} - -class SelectHeaderStyleButton extends StatefulWidget { - const SelectHeaderStyleButton({required this.controller, Key? key}) - : super(key: key); - - final QuillController controller; - - @override - _SelectHeaderStyleButtonState createState() => - _SelectHeaderStyleButtonState(); -} - -class _SelectHeaderStyleButtonState extends State { - Attribute? _value; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - }); - } - - void _selectAttribute(value) { - widget.controller.formatSelection(value); - } - - @override - void initState() { - super.initState(); - setState(() { - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - }); - widget.controller.addListener(_didChangeEditingValue); - } - - @override - void didUpdateWidget(covariant SelectHeaderStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _selectHeadingStyleButtonBuilder( - context, _value, _selectAttribute, widget.controller.iconSize); - } -} - -Widget _selectHeadingStyleButtonBuilder(BuildContext context, Attribute? value, - ValueChanged onSelected, double iconSize) { - final _valueToText = { - Attribute.header: 'N', - Attribute.h1: 'H1', - Attribute.h2: 'H2', - Attribute.h3: 'H3', - }; - - final _valueAttribute = [ - Attribute.header, - Attribute.h1, - Attribute.h2, - Attribute.h3 - ]; - final _valueString = ['N', 'H1', 'H2', 'H3']; - - final theme = Theme.of(context); - final style = TextStyle( - fontWeight: FontWeight.w600, - fontSize: iconSize * 0.7, - ); - - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(4, (index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), - child: ConstrainedBox( - constraints: BoxConstraints.tightFor( - width: iconSize * 1.77, - height: iconSize * 1.77, - ), - child: RawMaterialButton( - hoverElevation: 0, - highlightElevation: 0, - elevation: 0, - visualDensity: VisualDensity.compact, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - fillColor: _valueToText[value] == _valueString[index] - ? theme.toggleableActiveColor - : theme.canvasColor, - onPressed: () { - onSelected(_valueAttribute[index]); - }, - child: Text( - _valueString[index], - style: style.copyWith( - color: _valueToText[value] == _valueString[index] - ? theme.primaryIconTheme.color - : theme.iconTheme.color, - ), - ), - ), - ), - ); - }), - ); -} - -class ImageButton extends StatefulWidget { - const ImageButton({ - required this.icon, - required this.controller, - required this.imageSource, - this.onImagePickCallback, - this.imagePickImpl, - Key? key, - }) : super(key: key); - - final IconData icon; - - final QuillController controller; - - final OnImagePickCallback? onImagePickCallback; - - final ImagePickImpl? imagePickImpl; - - final ImageSource imageSource; - - @override - _ImageButtonState createState() => _ImageButtonState(); -} - -class _ImageButtonState extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return QuillIconButton( - icon: Icon( - widget.icon, - size: widget.controller.iconSize, - color: theme.iconTheme.color, - ), - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - fillColor: theme.canvasColor, - onPressed: _handleImageButtonTap, - ); - } - - Future _handleImageButtonTap() async { - final index = widget.controller.selection.baseOffset; - final length = widget.controller.selection.extentOffset - index; - - String? imageUrl; - if (widget.imagePickImpl != null) { - imageUrl = await widget.imagePickImpl!(widget.imageSource); - } else { - if (kIsWeb) { - imageUrl = await _pickImageWeb(); - } else if (Platform.isAndroid || Platform.isIOS) { - imageUrl = await _pickImage(widget.imageSource); - } else { - imageUrl = await _pickImageDesktop(); - } - } - - if (imageUrl != null) { - widget.controller - .replaceText(index, length, BlockEmbed.image(imageUrl), null); - } - } - - Future _pickImageWeb() async { - final result = await FilePicker.platform.pickFiles(); - if (result == null) { - return null; - } - - // Take first, because we don't allow picking multiple files. - final fileName = result.files.first.name!; - final file = File(fileName); - - return widget.onImagePickCallback!(file); - } - - Future _pickImage(ImageSource source) async { - final pickedFile = await ImagePicker().getImage(source: source); - if (pickedFile == null) { - return null; - } - - return widget.onImagePickCallback!(File(pickedFile.path)); - } - - Future _pickImageDesktop() async { - final filePath = await FilesystemPicker.open( - context: context, - rootDirectory: await getApplicationDocumentsDirectory(), - fsType: FilesystemType.file, - fileTileSelectMode: FileTileSelectMode.wholeTile, - ); - if (filePath == null || filePath.isEmpty) return null; - - final file = File(filePath); - return widget.onImagePickCallback!(file); - } -} - -/// Controls color styles. -/// -/// When pressed, this button displays overlay toolbar with -/// buttons for each color. -class ColorButton extends StatefulWidget { - const ColorButton({ - required this.icon, - required this.controller, - required this.background, - Key? key, - }) : super(key: key); - - final IconData icon; - final bool background; - final QuillController controller; - - @override - _ColorButtonState createState() => _ColorButtonState(); -} - -class _ColorButtonState extends State { - late bool _isToggledColor; - late bool _isToggledBackground; - late bool _isWhite; - late bool _isWhitebackground; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggledColor = - _getIsToggledColor(widget.controller.getSelectionStyle().attributes); - _isToggledBackground = _getIsToggledBackground( - widget.controller.getSelectionStyle().attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - }); - } - - @override - void initState() { - super.initState(); - _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggledColor(Map attrs) { - return attrs.containsKey(Attribute.color.key); - } - - bool _getIsToggledBackground(Map attrs) { - return attrs.containsKey(Attribute.background.key); - } - - @override - void didUpdateWidget(covariant ColorButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = - _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconColor = _isToggledColor && !widget.background && !_isWhite - ? stringToColor(_selectionStyle.attributes['color']!.value) - : theme.iconTheme.color; - - final iconColorBackground = - _isToggledBackground && widget.background && !_isWhitebackground - ? stringToColor(_selectionStyle.attributes['background']!.value) - : theme.iconTheme.color; - - final fillColor = _isToggledColor && !widget.background && _isWhite - ? stringToColor('#ffffff') - : theme.canvasColor; - final fillColorBackground = - _isToggledBackground && widget.background && _isWhitebackground - ? stringToColor('#ffffff') - : theme.canvasColor; - - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, - size: widget.controller.iconSize, - color: widget.background ? iconColorBackground : iconColor), - fillColor: widget.background ? fillColorBackground : fillColor, - onPressed: _showColorPicker, - ); - } - - void _changeColor(Color color) { - var hex = color.value.toRadixString(16); - if (hex.startsWith('ff')) { - hex = hex.substring(2); - } - hex = '#$hex'; - widget.controller.formatSelection( - widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); - Navigator.of(context).pop(); - } - - void _showColorPicker() { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Select Color'), - backgroundColor: Theme.of(context).canvasColor, - content: SingleChildScrollView( - child: MaterialPicker( - pickerColor: const Color(0x00000000), - onColorChanged: _changeColor, - ), - )), - ); - } -} - -class HistoryButton extends StatefulWidget { - const HistoryButton({ - required this.icon, - required this.controller, - required this.undo, - Key? key, - }) : super(key: key); - - final IconData icon; - final bool undo; - final QuillController controller; - - @override - _HistoryButtonState createState() => _HistoryButtonState(); -} - -class _HistoryButtonState extends State { - Color? _iconColor; - late ThemeData theme; - - @override - Widget build(BuildContext context) { - theme = Theme.of(context); - _setIconColor(); - - final fillColor = theme.canvasColor; - widget.controller.changes.listen((event) async { - _setIconColor(); - }); - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, - size: widget.controller.iconSize, color: _iconColor), - fillColor: fillColor, - onPressed: _changeHistory, - ); - } - - void _setIconColor() { - if (!mounted) return; - - if (widget.undo) { - setState(() { - _iconColor = widget.controller.hasUndo - ? theme.iconTheme.color - : theme.disabledColor; - }); - } else { - setState(() { - _iconColor = widget.controller.hasRedo - ? theme.iconTheme.color - : theme.disabledColor; - }); - } - } - - void _changeHistory() { - if (widget.undo) { - if (widget.controller.hasUndo) { - widget.controller.undo(); - } - } else { - if (widget.controller.hasRedo) { - widget.controller.redo(); - } - } - - _setIconColor(); - } -} - -class IndentButton extends StatefulWidget { - const IndentButton({ - required this.icon, - required this.controller, - required this.isIncrease, - Key? key, - }) : super(key: key); - - final IconData icon; - final QuillController controller; - final bool isIncrease; - - @override - _IndentButtonState createState() => _IndentButtonState(); -} - -class _IndentButtonState extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconColor = theme.iconTheme.color; - final fillColor = theme.canvasColor; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: - Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), - fillColor: fillColor, - onPressed: () { - final indent = widget.controller - .getSelectionStyle() - .attributes[Attribute.indent.key]; - if (indent == null) { - if (widget.isIncrease) { - widget.controller.formatSelection(Attribute.indentL1); - } - return; - } - if (indent.value == 1 && !widget.isIncrease) { - widget.controller - .formatSelection(Attribute.clone(Attribute.indentL1, null)); - return; - } - if (widget.isIncrease) { - widget.controller - .formatSelection(Attribute.getIndentLevel(indent.value + 1)); - return; - } - widget.controller - .formatSelection(Attribute.getIndentLevel(indent.value - 1)); - }, - ); - } -} - -class ClearFormatButton extends StatefulWidget { - const ClearFormatButton({ - required this.icon, - required this.controller, - Key? key, - }) : super(key: key); - - final IconData icon; - - final QuillController controller; - - @override - _ClearFormatButtonState createState() => _ClearFormatButtonState(); -} - -class _ClearFormatButtonState extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconColor = theme.iconTheme.color; - final fillColor = theme.canvasColor; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, - size: widget.controller.iconSize, color: iconColor), - fillColor: fillColor, - onPressed: () { - for (final k - in widget.controller.getSelectionStyle().attributes.values) { - widget.controller.formatSelection(Attribute.clone(k, null)); - } - }); - } -} - -class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { - const QuillToolbar( - {required this.children, this.toolBarHeight = 36, Key? key}) - : super(key: key); - - factory QuillToolbar.basic({ - required QuillController controller, - double toolbarIconSize = 18.0, - bool showBoldButton = true, - bool showItalicButton = true, - bool showUnderLineButton = true, - bool showStrikeThrough = true, - bool showColorButton = true, - bool showBackgroundColorButton = true, - bool showClearFormat = true, - bool showHeaderStyle = true, - bool showListNumbers = true, - bool showListBullets = true, - bool showListCheck = true, - bool showCodeBlock = true, - bool showQuote = true, - bool showIndent = true, - bool showLink = true, - bool showHistory = true, - bool showHorizontalRule = false, - OnImagePickCallback? onImagePickCallback, - Key? key, - }) { - controller.iconSize = toolbarIconSize; - - return QuillToolbar( - key: key, - toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor, - children: [ - Visibility( - visible: showHistory, - child: HistoryButton( - icon: Icons.undo_outlined, - controller: controller, - undo: true, - ), - ), - Visibility( - visible: showHistory, - child: HistoryButton( - icon: Icons.redo_outlined, - controller: controller, - undo: false, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showBoldButton, - child: ToggleStyleButton( - attribute: Attribute.bold, - icon: Icons.format_bold, - controller: controller, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showItalicButton, - child: ToggleStyleButton( - attribute: Attribute.italic, - icon: Icons.format_italic, - controller: controller, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showUnderLineButton, - child: ToggleStyleButton( - attribute: Attribute.underline, - icon: Icons.format_underline, - controller: controller, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showStrikeThrough, - child: ToggleStyleButton( - attribute: Attribute.strikeThrough, - icon: Icons.format_strikethrough, - controller: controller, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showColorButton, - child: ColorButton( - icon: Icons.color_lens, - controller: controller, - background: false, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showBackgroundColorButton, - child: ColorButton( - icon: Icons.format_color_fill, - controller: controller, - background: true, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: showClearFormat, - child: ClearFormatButton( - icon: Icons.format_clear, - controller: controller, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: onImagePickCallback != null, - child: ImageButton( - icon: Icons.image, - controller: controller, - imageSource: ImageSource.gallery, - onImagePickCallback: onImagePickCallback, - ), - ), - const SizedBox(width: 0.6), - Visibility( - visible: onImagePickCallback != null, - child: ImageButton( - icon: Icons.photo_camera, - controller: controller, - imageSource: ImageSource.camera, - onImagePickCallback: onImagePickCallback, - ), - ), - Visibility( - visible: showHeaderStyle, - child: VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400)), - Visibility( - visible: showHeaderStyle, - child: SelectHeaderStyleButton(controller: controller)), - VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400), - Visibility( - visible: showListNumbers, - child: ToggleStyleButton( - attribute: Attribute.ol, - controller: controller, - icon: Icons.format_list_numbered, - ), - ), - Visibility( - visible: showListBullets, - child: ToggleStyleButton( - attribute: Attribute.ul, - controller: controller, - icon: Icons.format_list_bulleted, - ), - ), - Visibility( - visible: showListCheck, - child: ToggleCheckListButton( - attribute: Attribute.unchecked, - controller: controller, - icon: Icons.check_box, - ), - ), - Visibility( - visible: showCodeBlock, - child: ToggleStyleButton( - attribute: Attribute.codeBlock, - controller: controller, - icon: Icons.code, - ), - ), - Visibility( - visible: !showListNumbers && - !showListBullets && - !showListCheck && - !showCodeBlock, - child: VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400)), - Visibility( - visible: showQuote, - child: ToggleStyleButton( - attribute: Attribute.blockQuote, - controller: controller, - icon: Icons.format_quote, - ), - ), - Visibility( - visible: showIndent, - child: IndentButton( - icon: Icons.format_indent_increase, - controller: controller, - isIncrease: true, - ), - ), - Visibility( - visible: showIndent, - child: IndentButton( - icon: Icons.format_indent_decrease, - controller: controller, - isIncrease: false, - ), - ), - Visibility( - visible: showQuote, - child: VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400)), - Visibility( - visible: showLink, - child: LinkStyleButton(controller: controller)), - Visibility( - visible: showHorizontalRule, - child: InsertEmbedButton( - controller: controller, - icon: Icons.horizontal_rule, - ), - ), - ]); - } - - final List children; - final double toolBarHeight; - - @override - _QuillToolbarState createState() => _QuillToolbarState(); - - @override - Size get preferredSize => Size.fromHeight(toolBarHeight); -} - -class _QuillToolbarState extends State { - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - constraints: BoxConstraints.tightFor(height: widget.preferredSize.height), - color: Theme.of(context).canvasColor, - child: CustomScrollView( - scrollDirection: Axis.horizontal, - slivers: [ - SliverFillRemaining( - hasScrollBody: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: widget.children, - ), - ), - ], - ), - ); - } -} - -class QuillIconButton extends StatelessWidget { - const QuillIconButton({ - required this.onPressed, - this.icon, - this.size = 40, - this.fillColor, - this.hoverElevation = 1, - this.highlightElevation = 1, - Key? key, - }) : super(key: key); - - final VoidCallback? onPressed; - final Widget? icon; - final double size; - final Color? fillColor; - final double hoverElevation; - final double highlightElevation; - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints.tightFor(width: size, height: size), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - fillColor: fillColor, - elevation: 0, - hoverElevation: hoverElevation, - highlightElevation: hoverElevation, - onPressed: onPressed, - child: icon, - ), - ); - } -} - -class QuillDropdownButton extends StatefulWidget { - const QuillDropdownButton({ - required this.child, - required this.initialValue, - required this.items, - required this.onSelected, - this.height = 40, - this.fillColor, - this.hoverElevation = 1, - this.highlightElevation = 1, - Key? key, - }) : super(key: key); - - final double height; - final Color? fillColor; - final double hoverElevation; - final double highlightElevation; - final Widget child; - final T initialValue; - final List> items; - final ValueChanged onSelected; - - @override - _QuillDropdownButtonState createState() => _QuillDropdownButtonState(); -} - -class _QuillDropdownButtonState extends State> { - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints.tightFor(height: widget.height), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - fillColor: widget.fillColor, - elevation: 0, - hoverElevation: widget.hoverElevation, - highlightElevation: widget.hoverElevation, - onPressed: _showMenu, - child: _buildContent(context), - ), - ); - } - - void _showMenu() { - final popupMenuTheme = PopupMenuTheme.of(context); - final button = context.findRenderObject() as RenderBox; - final overlay = - Overlay.of(context)!.context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal(button.size.bottomLeft(Offset.zero), - ancestor: overlay), - ), - Offset.zero & overlay.size, - ); - showMenu( - context: context, - elevation: 4, - // widget.elevation ?? popupMenuTheme.elevation, - initialValue: widget.initialValue, - items: widget.items, - position: position, - shape: popupMenuTheme.shape, - // widget.shape ?? popupMenuTheme.shape, - color: popupMenuTheme.color, // widget.color ?? popupMenuTheme.color, - // captureInheritedThemes: widget.captureInheritedThemes, - ).then((newValue) { - if (!mounted) return null; - if (newValue == null) { - // if (widget.onCanceled != null) widget.onCanceled(); - return null; - } - widget.onSelected(newValue); - }); - } - - Widget _buildContent(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints.tightFor(width: 110), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - widget.child, - Expanded(child: Container()), - const Icon(Icons.arrow_drop_down, size: 15) - ], - ), - ), - ); - } -} +/// TODO: Remove this file in the next breaking release, because implementation +/// files should be located in the src folder, https://bit.ly/3fA23Yz. +export '../../src/widgets/toolbar.dart'; From 95359f66b5690d3d300c69f90394447d9620ed29 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 21 May 2021 22:48:25 +0100 Subject: [PATCH 14/45] Fixes for flutter web (#234) * Fix for Attribute object comparison * Fix for "Unexpected null value" error on web Clipboard is now supported on web, via a permission request through the browser Co-authored-by: George Johnson --- lib/src/models/documents/attribute.dart | 2 +- lib/src/widgets/raw_editor.dart | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index 1b9043b9..acd979cb 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -193,7 +193,7 @@ class Attribute { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! Attribute) return false; + if (other is! Attribute) return false; final typedOther = other; return key == typedOther.key && scope == typedOther.scope && diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index cd4f379c..e52e18cd 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -126,8 +126,7 @@ class RawEditorState extends EditorState DefaultStyles? _styles; - final ClipboardStatusNotifier? _clipboardStatus = - kIsWeb ? null : ClipboardStatusNotifier(); + final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); final LayerLink _toolbarLayerLink = LayerLink(); final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); @@ -318,7 +317,7 @@ class RawEditorState extends EditorState void initState() { super.initState(); - _clipboardStatus?.addListener(_onChangedClipboardStatus); + _clipboardStatus.addListener(_onChangedClipboardStatus); widget.controller.addListener(() { _didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange); @@ -438,8 +437,9 @@ class RawEditorState extends EditorState widget.focusNode.removeListener(_handleFocusChanged); _focusAttachment!.detach(); _cursorCont.dispose(); - _clipboardStatus?.removeListener(_onChangedClipboardStatus); - _clipboardStatus?.dispose(); + _clipboardStatus + ..removeListener(_onChangedClipboardStatus) + ..dispose(); super.dispose(); } @@ -518,7 +518,7 @@ class RawEditorState extends EditorState this, DragStartBehavior.start, null, - _clipboardStatus!, + _clipboardStatus, ); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.showHandles(); From 292609871c2e2c717cb164be5f472d39bd7981bf Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Fri, 21 May 2021 23:54:35 +0200 Subject: [PATCH 15/45] Dispose ValueNotifier of cursor controller --- lib/src/widgets/cursor.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 963bc2e7..d0bc91b2 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -92,6 +92,9 @@ class CursorCont extends ChangeNotifier { _blinkOpacityCont.removeListener(_onColorTick); stopCursorTimer(); _blinkOpacityCont.dispose(); + show.dispose(); + _blink.dispose(); + color.dispose(); assert(_cursorTimer == null); super.dispose(); } From 3b429840b6b33d799bfd52990a84c63fe93b6331 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Fri, 21 May 2021 23:58:47 +0200 Subject: [PATCH 16/45] Remove getter for final operator A getter for a final variable makes no sense, because the variable cannot be reassigned. It is better to remove the unnecessary getter and make the variable public. --- lib/src/widgets/cursor.dart | 12 ++++-------- lib/src/widgets/text_line.dart | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index d0bc91b2..67ac45f5 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -60,7 +60,7 @@ class CursorCont extends ChangeNotifier { required CursorStyle style, required TickerProvider tickerProvider, }) : _style = style, - _blink = ValueNotifier(false), + blink = ValueNotifier(false), color = ValueNotifier(style.color) { _blinkOpacityCont = AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); @@ -68,17 +68,13 @@ class CursorCont extends ChangeNotifier { } final ValueNotifier show; - final ValueNotifier _blink; + final ValueNotifier blink; final ValueNotifier color; late AnimationController _blinkOpacityCont; Timer? _cursorTimer; bool _targetCursorVisibility = false; CursorStyle _style; - ValueNotifier get cursorBlink => _blink; - - ValueNotifier get cursorColor => color; - CursorStyle get style => _style; set style(CursorStyle value) { @@ -93,7 +89,7 @@ class CursorCont extends ChangeNotifier { stopCursorTimer(); _blinkOpacityCont.dispose(); show.dispose(); - _blink.dispose(); + blink.dispose(); color.dispose(); assert(_cursorTimer == null); super.dispose(); @@ -154,7 +150,7 @@ class CursorCont extends ChangeNotifier { void _onColorTick() { color.value = _style.color.withOpacity(_blinkOpacityCont.value); - _blink.value = show.value && _blinkOpacityCont.value > 0; + blink.value = show.value && _blinkOpacityCont.value > 0; } } diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index ef80a024..2bac327b 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -575,7 +575,7 @@ class RenderEditableTextLine extends RenderEditableBox { } if (containsCursor()) { cursorCont.addListener(markNeedsLayout); - cursorCont.cursorColor.addListener(markNeedsPaint); + cursorCont.color.addListener(markNeedsPaint); } } @@ -587,7 +587,7 @@ class RenderEditableTextLine extends RenderEditableBox { } if (containsCursor()) { cursorCont.removeListener(markNeedsLayout); - cursorCont.cursorColor.removeListener(markNeedsPaint); + cursorCont.color.removeListener(markNeedsPaint); } } @@ -728,7 +728,7 @@ class RenderEditableTextLine extends RenderEditableBox { _body, cursorCont.style, _caretPrototype, - cursorCont.cursorColor.value, + cursorCont.color.value, devicePixelRatio, ); From 1fedaf0d13ec3af514df5cd1591623ed6f46e5ae Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 01:07:25 +0200 Subject: [PATCH 17/45] Add comments to cursor class --- lib/src/widgets/cursor.dart | 135 +++++++++++++++++++++++++-------- lib/src/widgets/text_line.dart | 2 +- 2 files changed, 105 insertions(+), 32 deletions(-) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 67ac45f5..29aeed11 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -5,8 +5,7 @@ import 'package:flutter/widgets.dart'; import 'box.dart'; -const Duration _FADE_DURATION = Duration(milliseconds: 250); - +/// Style properties of editing cursor. class CursorStyle { const CursorStyle({ required this.color, @@ -19,13 +18,52 @@ class CursorStyle { this.paintAboveText = false, }); + /// The color to use when painting the cursor. final Color color; + + /// The color to use when painting the background cursor aligned with the text + /// while rendering the floating cursor. final Color backgroundColor; + + /// How thick the cursor will be. + /// + /// The cursor will draw under the text. The cursor width will extend + /// to the right of the boundary between characters for left-to-right text + /// and to the left for right-to-left text. This corresponds to extending + /// downstream relative to the selected position. Negative values may be used + /// to reverse this behavior. final double width; + + /// How tall the cursor will be. + /// + /// By default, the cursor height is set to the preferred line height of the + /// text. final double? height; + + /// How rounded the corners of the cursor should be. + /// + /// By default, the cursor has no radius. final Radius? radius; + + /// The offset that is used, in pixels, when painting the cursor on screen. + /// + /// By default, the cursor position should be set to an offset of + /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android + /// platforms. The origin from where the offset is applied to is the arbitrary + /// location where the cursor ends up being rendered from by default. final Offset? offset; + + /// Whether the cursor will animate from fully transparent to fully opaque + /// during each cursor blink. + /// + /// By default, the cursor opacity will animate on iOS platforms and will not + /// animate on Android platforms. final bool opacityAnimates; + + /// If the cursor should be painted on top of the text or underneath it. + /// + /// By default, the cursor should be painted on top for iOS platforms and + /// underneath for Android platforms. final bool paintAboveText; @override @@ -54,6 +92,10 @@ class CursorStyle { paintAboveText.hashCode; } +/// Controls the cursor of an editable widget. +/// +/// This class is a [ChangeNotifier] and allows to listen for updates on the +/// cursor [style]. class CursorCont extends ChangeNotifier { CursorCont({ required this.show, @@ -62,21 +104,35 @@ class CursorCont extends ChangeNotifier { }) : _style = style, blink = ValueNotifier(false), color = ValueNotifier(style.color) { - _blinkOpacityCont = - AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); - _blinkOpacityCont.addListener(_onColorTick); + _blinkOpacityController = + AnimationController(vsync: tickerProvider, duration: _fadeDuration); + _blinkOpacityController.addListener(_onColorTick); } + // The time it takes for the cursor to fade from fully opaque to fully + // transparent and vice versa. A full cursor blink, from transparent to opaque + // to transparent, is twice this duration. + static const Duration _blinkHalfPeriod = Duration(milliseconds: 500); + + // The time the cursor is static in opacity before animating to become + // transparent. + static const Duration _blinkWaitForStart = Duration(milliseconds: 150); + + // This value is an eyeball estimation of the time it takes for the iOS cursor + // to ease in and out. + static const Duration _fadeDuration = Duration(milliseconds: 250); + final ValueNotifier show; - final ValueNotifier blink; final ValueNotifier color; - late AnimationController _blinkOpacityCont; + final ValueNotifier blink; + + late final AnimationController _blinkOpacityController; + Timer? _cursorTimer; bool _targetCursorVisibility = false; - CursorStyle _style; + CursorStyle _style; CursorStyle get style => _style; - set style(CursorStyle value) { if (_style == value) return; _style = value; @@ -85,9 +141,9 @@ class CursorCont extends ChangeNotifier { @override void dispose() { - _blinkOpacityCont.removeListener(_onColorTick); + _blinkOpacityController.removeListener(_onColorTick); stopCursorTimer(); - _blinkOpacityCont.dispose(); + _blinkOpacityController.dispose(); show.dispose(); blink.dispose(); color.dispose(); @@ -99,28 +155,32 @@ class CursorCont extends ChangeNotifier { _targetCursorVisibility = !_targetCursorVisibility; final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; if (style.opacityAnimates) { - _blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); + // If we want to show the cursor, we will animate the opacity to the value + // of 1.0, and likewise if we want to make it disappear, to 0.0. An easing + // curve is used for the animation to mimic the aesthetics of the native + // iOS cursor. + // + // These values and curves have been obtained through eyeballing, so are + // likely not exactly the same as the values for native iOS. + _blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut); } else { - _blinkOpacityCont.value = targetOpacity; + _blinkOpacityController.value = targetOpacity; } } - void _cursorWaitForStart(Timer timer) { + void _waitForStart(Timer timer) { _cursorTimer?.cancel(); - _cursorTimer = - Timer.periodic(const Duration(milliseconds: 500), _cursorTick); + _cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick); } void startCursorTimer() { _targetCursorVisibility = true; - _blinkOpacityCont.value = 1.0; + _blinkOpacityController.value = 1.0; if (style.opacityAnimates) { - _cursorTimer = Timer.periodic( - const Duration(milliseconds: 150), _cursorWaitForStart); + _cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart); } else { - _cursorTimer = - Timer.periodic(const Duration(milliseconds: 500), _cursorTick); + _cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick); } } @@ -128,10 +188,10 @@ class CursorCont extends ChangeNotifier { _cursorTimer?.cancel(); _cursorTimer = null; _targetCursorVisibility = false; - _blinkOpacityCont.value = 0.0; + _blinkOpacityController.value = 0.0; if (style.opacityAnimates) { - _blinkOpacityCont + _blinkOpacityController ..stop() ..value = 0.0; } @@ -149,32 +209,42 @@ class CursorCont extends ChangeNotifier { } void _onColorTick() { - color.value = _style.color.withOpacity(_blinkOpacityCont.value); - blink.value = show.value && _blinkOpacityCont.value > 0; + color.value = _style.color.withOpacity(_blinkOpacityController.value); + blink.value = show.value && _blinkOpacityController.value > 0; } } +/// Paints the editing cursor. class CursorPainter { - CursorPainter(this.editable, this.style, this.prototype, this.color, - this.devicePixelRatio); + CursorPainter( + this.editable, + this.style, + this.prototype, + this.color, + this.devicePixelRatio, + ); final RenderContentProxyBox? editable; final CursorStyle style; - final Rect? prototype; + final Rect prototype; final Color color; final double devicePixelRatio; + /// Paints cursor on [canvas] at specified [position]. void paint(Canvas canvas, Offset offset, TextPosition position) { - assert(prototype != null); - final caretOffset = editable!.getOffsetForCaret(position, prototype) + offset; - var caretRect = prototype!.shift(caretOffset); + var caretRect = prototype.shift(caretOffset); if (style.offset != null) { caretRect = caretRect.shift(style.offset!); } if (caretRect.left < 0.0) { + // For iOS the cursor may get clipped by the scroll view when + // it's located at a beginning of a line. We ensure that this + // does not happen here. This may result in the cursor being painted + // closer to the character on the right, but it's arguably better + // then painting clipped cursor (or even cursor completely hidden). caretRect = caretRect.shift(Offset(-caretRect.left, 0)); } @@ -185,6 +255,8 @@ class CursorPainter { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: + // Override the height to take the full height of the glyph at the TextPosition + // when not on iOS. iOS has special handling that creates a taller caret. caretRect = Rect.fromLTWH( caretRect.left, caretRect.top - 2.0, @@ -194,6 +266,7 @@ class CursorPainter { break; case TargetPlatform.iOS: case TargetPlatform.macOS: + // Center the caret vertically along the text. caretRect = Rect.fromLTWH( caretRect.left, caretRect.top + (caretHeight - caretRect.height) / 2, diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 2bac327b..04a9567d 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -727,7 +727,7 @@ class RenderEditableTextLine extends RenderEditableBox { CursorPainter get _cursorPainter => CursorPainter( _body, cursorCont.style, - _caretPrototype, + _caretPrototype!, cursorCont.color.value, devicePixelRatio, ); From 4c11b28cb902549d4ef7bcd0adcf24d8291ce0d4 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 01:20:51 +0200 Subject: [PATCH 18/45] Remove null exception when a disposed controller is set --- lib/src/widgets/cursor.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 29aeed11..44ba564f 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -139,10 +139,18 @@ class CursorCont extends ChangeNotifier { notifyListeners(); } + /// True when this [CursorCont] instance has been disposed. + /// + /// A safety mechanism to prevent the value of a disposed controller from + /// getting set. + bool _isDisposed = false; + @override void dispose() { _blinkOpacityController.removeListener(_onColorTick); stopCursorTimer(); + + _isDisposed = true; _blinkOpacityController.dispose(); show.dispose(); blink.dispose(); @@ -174,6 +182,10 @@ class CursorCont extends ChangeNotifier { } void startCursorTimer() { + if (_isDisposed) { + return; + } + _targetCursorVisibility = true; _blinkOpacityController.value = 1.0; From 102883dfef51f7206464114d17f016ceac7b7f6b Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 10:41:24 +0200 Subject: [PATCH 19/45] Disallow lines longer than 80 characters --- analysis_options.yaml | 1 + example/lib/pages/home_page.dart | 3 ++- example/lib/universal_ui/universal_ui.dart | 7 ++++--- lib/src/models/documents/document.dart | 2 +- lib/src/models/documents/nodes/container.dart | 2 +- lib/src/models/quill_delta.dart | 5 +++-- lib/src/models/rules/insert.dart | 3 ++- lib/src/utils/diff_delta.dart | 3 ++- lib/src/widgets/controller.dart | 2 +- lib/src/widgets/cursor.dart | 5 +++-- lib/src/widgets/editor.dart | 7 ++++--- lib/src/widgets/keyboard_listener.dart | 2 +- lib/src/widgets/simple_viewer.dart | 8 +++++--- lib/src/widgets/text_block.dart | 3 ++- 14 files changed, 32 insertions(+), 21 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 2fc4fec6..06d7ee1c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -17,6 +17,7 @@ linter: - avoid_void_async - cascade_invocations - directives_ordering + - lines_longer_than_80_chars - omit_local_variable_types - prefer_const_constructors - prefer_const_constructors_in_immutables diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index d40b4be2..e0f436f8 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -170,7 +170,8 @@ class _HomePageState extends State { } // Renders the image picked by imagePicker from local file storage - // You can also upload the picked image to any server (eg : AWS s3 or Firebase) and then return the uploaded image URL + // You can also upload the picked image to any server (eg : AWS s3 + // or Firebase) and then return the uploaded image URL. Future _onImagePickCallback(File file) async { // Copies the picked file from temporary cache to applications directory final appDocDir = await getApplicationDocumentsDirectory(); diff --git a/example/lib/universal_ui/universal_ui.dart b/example/lib/universal_ui/universal_ui.dart index d1bdc3f5..567aa6b4 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -50,8 +50,9 @@ Widget defaultEmbedBuilderWeb(BuildContext context, Embed node) { 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.'); + '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.', + ); } } diff --git a/lib/src/models/documents/document.dart b/lib/src/models/documents/document.dart index a26d885a..346da93b 100644 --- a/lib/src/models/documents/document.dart +++ b/lib/src/models/documents/document.dart @@ -250,7 +250,7 @@ class Document { for (final op in doc.toList()) { if (!op.isInsert) { throw ArgumentError.value(doc, - 'Document Delta can only contain insert operations but ${op.key} found.'); + 'Document can only contain insert operations but ${op.key} found.'); } final style = op.attributes != null ? Style.fromJson(op.attributes) : null; diff --git a/lib/src/models/documents/nodes/container.dart b/lib/src/models/documents/nodes/container.dart index dbdd12d1..13f6aa2f 100644 --- a/lib/src/models/documents/nodes/container.dart +++ b/lib/src/models/documents/nodes/container.dart @@ -79,7 +79,7 @@ abstract class Container extends Node { if (last != null) last.adjust(); } - /// Queries the child [Node] at specified character [offset] in this container. + /// Queries the child [Node] at [offset] in this container. /// /// The result may contain the found node or `null` if no node is found /// at specified offset. diff --git a/lib/src/models/quill_delta.dart b/lib/src/models/quill_delta.dart index a0e608be..548e2afa 100644 --- a/lib/src/models/quill_delta.dart +++ b/lib/src/models/quill_delta.dart @@ -1,5 +1,6 @@ -// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code -// is governed by a BSD-style license that can be found in the LICENSE file. +// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this +// source code is governed by a BSD-style license that can be found in the +// LICENSE file. /// Implementation of Quill Delta format in Dart. library quill_delta; diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index 5801a10e..9c10d422 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -167,7 +167,8 @@ class AutoExitBlockRule extends InsertRule { // First check if `cur` length is greater than 1, this would indicate // that it contains multiple newline characters which share the same style. // This would mean we are not on the last line yet. - // `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline + // `cur.value as String` is safe since we already called isEmptyLine and + // know it contains a newline if ((cur.value as String).length > 1) { // We are not on the last line of this block, ignore. return null; diff --git a/lib/src/utils/diff_delta.dart b/lib/src/utils/diff_delta.dart index 003bae47..0e09946c 100644 --- a/lib/src/utils/diff_delta.dart +++ b/lib/src/utils/diff_delta.dart @@ -79,7 +79,8 @@ int getPositionDelta(Delta user, Delta actual) { final userOperation = userItr.next(length as int); final actualOperation = actualItr.next(length); if (userOperation.length != actualOperation.length) { - throw 'userOp ${userOperation.length} does not match actualOp ${actualOperation.length}'; + throw 'userOp ${userOperation.length} does not match actualOp ' + '${actualOperation.length}'; } if (userOperation.key == actualOperation.key) { continue; diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index bd669171..9edf805b 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -69,7 +69,7 @@ class QuillController extends ChangeNotifier { // if (this.selection.extentOffset >= document.length) { // // cursor exceeds the length of document, position it in the end // updateSelection( - // TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL); + // TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL); updateSelection( TextSelection.collapsed(offset: selection.baseOffset + len!), ChangeSource.LOCAL); diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 44ba564f..7467c0a3 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -267,8 +267,9 @@ class CursorPainter { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - // Override the height to take the full height of the glyph at the TextPosition - // when not on iOS. iOS has special handling that creates a taller caret. + // Override the height to take the full height of the glyph at the + // TextPosition when not on iOS. iOS has special handling that + // creates a taller caret. caretRect = Rect.fromLTWH( caretRect.left, caretRect.top - 2.0, diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 662a018c..fb90db76 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -107,9 +107,10 @@ Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) { : 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.'); + '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.', + ); } } diff --git a/lib/src/widgets/keyboard_listener.dart b/lib/src/widgets/keyboard_listener.dart index 17c47aad..58df1725 100644 --- a/lib/src/widgets/keyboard_listener.dart +++ b/lib/src/widgets/keyboard_listener.dart @@ -64,7 +64,7 @@ class KeyboardListener { bool handleRawKeyEvent(RawKeyEvent event) { if (kIsWeb) { - // On web platform, we should ignore the key because it's processed already. + // On web platform, we ignore the key because it's already processed. return false; } diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart index ee1a7732..559e5e1a 100644 --- a/lib/src/widgets/simple_viewer.dart +++ b/lib/src/widgets/simple_viewer.dart @@ -109,9 +109,11 @@ class _QuillSimpleViewerState extends State : 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.'); + '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.', + ); } } diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index f533a160..92c80f39 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -512,7 +512,8 @@ class RenderEditableTextBlock extends RenderEditableContainerBox offset.translate(decorationPadding.left, decorationPadding.top); _painter!.paint(context.canvas, decorationOffset, filledConfiguration); if (debugSaveCount != context.canvas.getSaveCount()) { - throw '${_decoration.runtimeType} painter had mismatching save and restore calls.'; + throw '${_decoration.runtimeType} painter had mismatching save and ' + 'restore calls.'; } if (decoration.isComplex) { context.setIsComplexHint(); From ea0dbd5ce06ad285a77c51e79131e3f185c9af8a Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 10:44:41 +0200 Subject: [PATCH 20/45] Don't create a lambda when a tear-off will do --- analysis_options.yaml | 1 + lib/src/models/rules/insert.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 06d7ee1c..7749c861 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -32,5 +32,6 @@ linter: - prefer_single_quotes - sort_constructors_first - sort_unnamed_constructors_first + - unnecessary_lambdas - unnecessary_parenthesis - unnecessary_string_interpolations diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index 9c10d422..f50be23f 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -189,7 +189,7 @@ class AutoExitBlockRule extends InsertRule { // therefore we can exit this block. final attributes = cur.attributes ?? {}; final k = attributes.keys - .firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k)); + .firstWhere(Attribute.blockKeysExceptHeader.contains); attributes[k] = null; // retain(1) should be '\n', set it with no attribute return Delta()..retain(index + (len ?? 0))..retain(1, attributes); From 0f6fd64bedd943feec3729103dc6f802445fed30 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 10:49:38 +0200 Subject: [PATCH 21/45] Move ResponsiveWidgets to example folder This widget has nothing to do with the library and is only used in the example, so it is moved to the example. --- example/lib/universal_ui/universal_ui.dart | 2 +- {lib/src => example/lib}/widgets/responsive_widget.dart | 0 lib/flutter_quill.dart | 1 - lib/widgets/responsive_widget.dart | 3 --- 4 files changed, 1 insertion(+), 5 deletions(-) rename {lib/src => example/lib}/widgets/responsive_widget.dart (100%) delete mode 100644 lib/widgets/responsive_widget.dart diff --git a/example/lib/universal_ui/universal_ui.dart b/example/lib/universal_ui/universal_ui.dart index 567aa6b4..95047a31 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -3,9 +3,9 @@ library universal_ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_quill/flutter_quill.dart'; - import 'package:universal_html/html.dart' as html; +import '../widgets/responsive_widget.dart'; import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance; class PlatformViewRegistryFix { diff --git a/lib/src/widgets/responsive_widget.dart b/example/lib/widgets/responsive_widget.dart similarity index 100% rename from lib/src/widgets/responsive_widget.dart rename to example/lib/widgets/responsive_widget.dart diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 049786cd..6b6754ce 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -8,5 +8,4 @@ export 'src/models/quill_delta.dart'; export 'src/widgets/controller.dart'; export 'src/widgets/default_styles.dart'; export 'src/widgets/editor.dart'; -export 'src/widgets/responsive_widget.dart'; export 'src/widgets/toolbar.dart'; diff --git a/lib/widgets/responsive_widget.dart b/lib/widgets/responsive_widget.dart deleted file mode 100644 index bb46820f..00000000 --- a/lib/widgets/responsive_widget.dart +++ /dev/null @@ -1,3 +0,0 @@ -/// TODO: Remove this file in the next breaking release, because implementation -/// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/responsive_widget.dart'; From 404857817b47d8e333029760fd2ab1b1f97e0e57 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 11:59:03 +0200 Subject: [PATCH 22/45] Fix null exception --- lib/src/widgets/raw_editor.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index e52e18cd..066ff675 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -559,13 +559,17 @@ class RawEditorState extends EditorState if (widget.scrollable) { _showCaretOnScreenScheduled = false; - final viewport = RenderAbstractViewport.of(getRenderEditor()); + final renderEditor = getRenderEditor(); + if (renderEditor == null) { + return; + } - final editorOffset = getRenderEditor()! - .localToGlobal(const Offset(0, 0), ancestor: viewport); + final viewport = RenderAbstractViewport.of(renderEditor); + final editorOffset = + renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport); final offsetInViewport = _scrollController!.offset + editorOffset.dy; - final offset = getRenderEditor()!.getOffsetToRevealCursor( + final offset = renderEditor.getOffsetToRevealCursor( _scrollController!.position.viewportDimension, _scrollController!.offset, offsetInViewport, @@ -584,7 +588,7 @@ class RawEditorState extends EditorState @override RenderEditor? getRenderEditor() { - return _editorKey.currentContext!.findRenderObject() as RenderEditor?; + return _editorKey.currentContext?.findRenderObject() as RenderEditor?; } @override From ee45815da522021e292fb95bd879717beecb74f9 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 12:34:09 +0200 Subject: [PATCH 23/45] Remove exception when widget is not mounted --- lib/src/widgets/raw_editor.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 066ff675..bf63dd49 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -483,8 +483,12 @@ class RawEditorState extends EditorState ..startCursorTimer(); } - SchedulerBinding.instance!.addPostFrameCallback( - (_) => _updateOrDisposeSelectionOverlayIfNeeded()); + SchedulerBinding.instance!.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _updateOrDisposeSelectionOverlayIfNeeded(); + }); if (mounted) { setState(() { // Use widget.controller.value in build() From f499c478edf06205f25e6e4bad2f67b42d9ff29c Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 13:25:32 +0200 Subject: [PATCH 24/45] Fix exception when rect is not a number --- lib/src/widgets/cursor.dart | 42 ++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 7467c0a3..9fef16e5 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -292,25 +292,39 @@ class CursorPainter { } } - final caretPosition = editable!.localToGlobal(caretRect.topLeft); - final pixelMultiple = 1.0 / devicePixelRatio; - caretRect = caretRect.shift(Offset( - caretPosition.dx.isFinite - ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - - caretPosition.dx - : caretPosition.dx, - caretPosition.dy.isFinite - ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - - caretPosition.dy - : caretPosition.dy)); + final pixelPerfectOffset = + _getPixelPerfectCursorOffset(editable!, caretRect, devicePixelRatio); + if (!pixelPerfectOffset.isFinite) { + return; + } + caretRect = caretRect.shift(pixelPerfectOffset); final paint = Paint()..color = color; if (style.radius == null) { canvas.drawRect(caretRect, paint); - return; + } else { + final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); + canvas.drawRRect(caretRRect, paint); } + } + + Offset _getPixelPerfectCursorOffset( + RenderContentProxyBox editable, + Rect caretRect, + double devicePixelRatio, + ) { + final caretPosition = editable.localToGlobal(caretRect.topLeft); + final pixelMultiple = 1.0 / devicePixelRatio; + + final pixelPerfectOffsetX = caretPosition.dx.isFinite + ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - + caretPosition.dx + : caretPosition.dx; + final pixelPerfectOffsetY = caretPosition.dy.isFinite + ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - + caretPosition.dy + : caretPosition.dy; - final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); - canvas.drawRRect(caretRRect, paint); + return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY); } } From 3d6a915d2684933b26fc2438456319b6c6e094ee Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 13:52:32 +0200 Subject: [PATCH 25/45] Fix paste (#236) closes #235. --- lib/src/widgets/raw_editor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index bf63dd49..b94472b6 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -680,7 +680,7 @@ class RawEditorState extends EditorState @override void userUpdateTextEditingValue( TextEditingValue value, SelectionChangedCause cause) { - // TODO: implement userUpdateTextEditingValue + updateEditingValue(value); } } From e6f1160d7107262e0fc807d31aa46d026520c9cd Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 14:48:06 +0200 Subject: [PATCH 26/45] Fix exception --- .../raw_editor_state_text_input_client_mixin.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart index 527df582..bfb36106 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart @@ -103,7 +103,11 @@ mixin RawEditorStateTextInputClientMixin on EditorState final shouldRemember = getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text; _lastKnownRemoteTextEditingValue = actualValue; - _textInputConnection!.setEditingState(actualValue); + _textInputConnection!.setEditingState( + // Set composing to (-1, -1), otherwise an exception will be thrown if + // the values are different. + actualValue.copyWith(composing: const TextRange(start: -1, end: -1)), + ); if (shouldRemember) { // Only keep track if text changed (selection changes are not relevant) _sentRemoteValues.add(actualValue); From f0459ba4d5aa3fb91c643e895b3c9c3ed924e76d Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 22 May 2021 15:20:52 +0200 Subject: [PATCH 27/45] Add const types for image and divider embeds This allows to reference the type. --- lib/src/models/documents/nodes/embed.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/models/documents/nodes/embed.dart b/lib/src/models/documents/nodes/embed.dart index d6fe628a..07e6a6b3 100644 --- a/lib/src/models/documents/nodes/embed.dart +++ b/lib/src/models/documents/nodes/embed.dart @@ -4,7 +4,7 @@ /// /// * [BlockEmbed] which represents a block embed. class Embeddable { - Embeddable(this.type, this.data); + const Embeddable(this.type, this.data); /// The type of this object. final String type; @@ -32,9 +32,11 @@ class Embeddable { /// the document model itself does not make any assumptions about the types /// of embedded objects and allows users to define their own types. class BlockEmbed extends Embeddable { - BlockEmbed(String type, String data) : super(type, data); + const BlockEmbed(String type, String data) : super(type, data); - static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr'); + static const String horizontalRuleType = 'divider'; + static BlockEmbed horizontalRule = const BlockEmbed(horizontalRuleType, 'hr'); - static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl); + static const String imageType = 'image'; + static BlockEmbed image(String imageUrl) => BlockEmbed(imageType, imageUrl); } From c0f0ded452a8c5ff8c4066d82d59ad8de8368a60 Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Sat, 22 May 2021 18:26:26 -0700 Subject: [PATCH 28/45] Fix relative path --- lib/models/documents/attribute.dart | 2 +- lib/models/documents/document.dart | 2 +- lib/models/documents/history.dart | 2 +- lib/models/documents/style.dart | 2 +- lib/models/quill_delta.dart | 2 +- lib/models/rules/delete.dart | 2 +- lib/models/rules/format.dart | 2 +- lib/models/rules/insert.dart | 2 +- lib/models/rules/rule.dart | 2 +- lib/utils/color.dart | 2 +- lib/utils/diff_delta.dart | 2 +- lib/widgets/box.dart | 2 +- lib/widgets/controller.dart | 2 +- lib/widgets/cursor.dart | 2 +- lib/widgets/default_styles.dart | 2 +- lib/widgets/delegate.dart | 2 +- lib/widgets/editor.dart | 2 +- lib/widgets/image.dart | 2 +- lib/widgets/keyboard_listener.dart | 2 +- lib/widgets/proxy.dart | 2 +- lib/widgets/raw_editor.dart | 2 +- lib/widgets/simple_viewer.dart | 2 +- lib/widgets/text_block.dart | 2 +- lib/widgets/text_line.dart | 2 +- lib/widgets/text_selection.dart | 2 +- lib/widgets/toolbar.dart | 2 +- 26 files changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/models/documents/attribute.dart b/lib/models/documents/attribute.dart index 7411e232..e106383e 100644 --- a/lib/models/documents/attribute.dart +++ b/lib/models/documents/attribute.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/documents/attribute.dart'; +export '../../src/models/documents/attribute.dart'; diff --git a/lib/models/documents/document.dart b/lib/models/documents/document.dart index d946618a..a187d19d 100644 --- a/lib/models/documents/document.dart +++ b/lib/models/documents/document.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/documents/document.dart'; +export '../../src/models/documents/document.dart'; diff --git a/lib/models/documents/history.dart b/lib/models/documents/history.dart index 3ff6870e..b07c8e33 100644 --- a/lib/models/documents/history.dart +++ b/lib/models/documents/history.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/documents/history.dart'; +export '../../src/models/documents/history.dart'; diff --git a/lib/models/documents/style.dart b/lib/models/documents/style.dart index a4e06de4..6df9412b 100644 --- a/lib/models/documents/style.dart +++ b/lib/models/documents/style.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/documents/style.dart'; +export '../../src/models/documents/style.dart'; diff --git a/lib/models/quill_delta.dart b/lib/models/quill_delta.dart index 477fbe33..796d68ca 100644 --- a/lib/models/quill_delta.dart +++ b/lib/models/quill_delta.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/quill_delta.dart'; +export '../src/models/quill_delta.dart'; diff --git a/lib/models/rules/delete.dart b/lib/models/rules/delete.dart index 65a27b0e..0430686b 100644 --- a/lib/models/rules/delete.dart +++ b/lib/models/rules/delete.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/rules/delete.dart'; +export '../../src/models/rules/delete.dart'; diff --git a/lib/models/rules/format.dart b/lib/models/rules/format.dart index e6251d03..7d642af3 100644 --- a/lib/models/rules/format.dart +++ b/lib/models/rules/format.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/rules/format.dart'; +export '../../src/models/rules/format.dart'; diff --git a/lib/models/rules/insert.dart b/lib/models/rules/insert.dart index 4dfe6ab7..04e7a4fa 100644 --- a/lib/models/rules/insert.dart +++ b/lib/models/rules/insert.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/rules/insert.dart'; +export '../../src/models/rules/insert.dart'; diff --git a/lib/models/rules/rule.dart b/lib/models/rules/rule.dart index 11026f46..ccea0dda 100644 --- a/lib/models/rules/rule.dart +++ b/lib/models/rules/rule.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/models/rules/rule.dart'; +export '../../src/models/rules/rule.dart'; diff --git a/lib/utils/color.dart b/lib/utils/color.dart index f126cf52..cfd8f803 100644 --- a/lib/utils/color.dart +++ b/lib/utils/color.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/utils/color.dart'; +export '../src/utils/color.dart'; diff --git a/lib/utils/diff_delta.dart b/lib/utils/diff_delta.dart index 08d30f51..607093c4 100644 --- a/lib/utils/diff_delta.dart +++ b/lib/utils/diff_delta.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../../src/utils/diff_delta.dart'; +export '../src/utils/diff_delta.dart'; diff --git a/lib/widgets/box.dart b/lib/widgets/box.dart index d97c610a..511c8149 100644 --- a/lib/widgets/box.dart +++ b/lib/widgets/box.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/box.dart'; +export '../src/widgets/box.dart'; diff --git a/lib/widgets/controller.dart b/lib/widgets/controller.dart index e1177f78..82cab553 100644 --- a/lib/widgets/controller.dart +++ b/lib/widgets/controller.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/controller.dart'; +export '../src/widgets/controller.dart'; diff --git a/lib/widgets/cursor.dart b/lib/widgets/cursor.dart index 3528ad16..540c9011 100644 --- a/lib/widgets/cursor.dart +++ b/lib/widgets/cursor.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/cursor.dart'; +export '../src/widgets/cursor.dart'; diff --git a/lib/widgets/default_styles.dart b/lib/widgets/default_styles.dart index 3fffda6f..6dd63fc8 100644 --- a/lib/widgets/default_styles.dart +++ b/lib/widgets/default_styles.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/default_styles.dart'; +export '../src/widgets/default_styles.dart'; diff --git a/lib/widgets/delegate.dart b/lib/widgets/delegate.dart index c1db553e..3a5057d2 100644 --- a/lib/widgets/delegate.dart +++ b/lib/widgets/delegate.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/delegate.dart'; +export '../src/widgets/delegate.dart'; diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index c0d754f9..db30762d 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/editor.dart'; +export '../src/widgets/editor.dart'; diff --git a/lib/widgets/image.dart b/lib/widgets/image.dart index 41a8a235..547279d1 100644 --- a/lib/widgets/image.dart +++ b/lib/widgets/image.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/image.dart'; +export '../src/widgets/image.dart'; diff --git a/lib/widgets/keyboard_listener.dart b/lib/widgets/keyboard_listener.dart index 0ee8d6e7..20c72b74 100644 --- a/lib/widgets/keyboard_listener.dart +++ b/lib/widgets/keyboard_listener.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/keyboard_listener.dart'; +export '../src/widgets/keyboard_listener.dart'; diff --git a/lib/widgets/proxy.dart b/lib/widgets/proxy.dart index 6d17bb7d..247f4897 100644 --- a/lib/widgets/proxy.dart +++ b/lib/widgets/proxy.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/proxy.dart'; +export '../src/widgets/proxy.dart'; diff --git a/lib/widgets/raw_editor.dart b/lib/widgets/raw_editor.dart index cf483dd1..84ebf6f2 100644 --- a/lib/widgets/raw_editor.dart +++ b/lib/widgets/raw_editor.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/raw_editor.dart'; +export '../src/widgets/raw_editor.dart'; diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart index 219babec..fbea8404 100644 --- a/lib/widgets/simple_viewer.dart +++ b/lib/widgets/simple_viewer.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/simple_viewer.dart'; +export '../src/widgets/simple_viewer.dart'; diff --git a/lib/widgets/text_block.dart b/lib/widgets/text_block.dart index 3e46ea80..b58e01ae 100644 --- a/lib/widgets/text_block.dart +++ b/lib/widgets/text_block.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/text_block.dart'; +export '../src/widgets/text_block.dart'; diff --git a/lib/widgets/text_line.dart b/lib/widgets/text_line.dart index 7c8dcc80..0d0e4098 100644 --- a/lib/widgets/text_line.dart +++ b/lib/widgets/text_line.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/text_line.dart'; +export '../src/widgets/text_line.dart'; diff --git a/lib/widgets/text_selection.dart b/lib/widgets/text_selection.dart index b35db3ea..e4c3f5e4 100644 --- a/lib/widgets/text_selection.dart +++ b/lib/widgets/text_selection.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/text_selection.dart'; +export '../src/widgets/text_selection.dart'; diff --git a/lib/widgets/toolbar.dart b/lib/widgets/toolbar.dart index c47f1353..1f9d4827 100644 --- a/lib/widgets/toolbar.dart +++ b/lib/widgets/toolbar.dart @@ -1,3 +1,3 @@ /// TODO: Remove this file in the next breaking release, because implementation /// files should be located in the src folder, https://bit.ly/3fA23Yz. -export '../../src/widgets/toolbar.dart'; +export '../src/widgets/toolbar.dart'; From 8c3617c669567b535a74a6e4879207dd73bb1adc Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Mon, 24 May 2021 20:10:54 +0200 Subject: [PATCH 29/45] Add new logo --- README.md | 70 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b7c1a247..bbedb500 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,35 @@ +

+ +

+

A rich text editor for Flutter

+ +[![MIT License][license-badge]][license-link] +[![PRs Welcome][prs-badge]][prs-link] +[![Watch on GitHub][github-watch-badge]][github-watch-link] +[![Star on GitHub][github-star-badge]][github-star-link] +[![Watch on GitHub][github-forks-badge]][github-forks-link] + +[license-badge]: https://img.shields.io/github/license/singerdmx/flutter-quill.svg?style=for-the-badge +[license-link]: https://github.com/singerdmx/flutter-quill/blob/master/LICENSE +[prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-link]: https://github.com/singerdmx/flutter-quill/issues +[github-watch-badge]: https://img.shields.io/github/watchers/singerdmx/flutter-quill.svg?style=for-the-badge&logo=github&logoColor=ffffff +[github-watch-link]: https://github.com/singerdmx/flutter-quill/watchers +[github-star-badge]: https://img.shields.io/github/stars/singerdmx/flutter-quill.svg?style=for-the-badge&logo=github&logoColor=ffffff +[github-star-link]: https://github.com/singerdmx/flutter-quill/stargazers +[github-forks-badge]: https://img.shields.io/github/forks/singerdmx/flutter-quill.svg?style=for-the-badge&logo=github&logoColor=ffffff +[github-forks-link]: https://github.com/singerdmx/flutter-quill/network/members + + +FlutterQuill is a rich text editor and a [Quill] component for [Flutter]. - - - -# FlutterQuill - -FlutterQuill is a rich text editor and a [Quill] component for [Flutter]. - This library is a WYSIWYG editor built for the modern mobile platform, with web compatibility under development. You can join our [Slack Group] for discussion. Demo App: https://bulletjournal.us/home/index.html Pub: https://pub.dev/packages/flutter_quill -## Usage +## Usage See the `example` directory for a minimal example of how to use FlutterQuill. You typically just need to instantiate a controller: @@ -79,17 +94,30 @@ It is required to provide EmbedBuilder, e.g. [defaultEmbedBuilderWeb](https://gi ## Migrate Zefyr Data Check out [code](https://github.com/jwehrle/zefyr_quill_convert) and [doc](https://docs.google.com/document/d/1FUSrpbarHnilb7uDN5J5DDahaI0v1RMXBjj4fFSpSuY/edit?usp=sharing). - ---- - -1 -1 -1 -1 - + +--- + +

+ 1 + 1 +

+ + +

+ 1 + 1 +

+ +## Sponsors + + + + [Quill]: https://quilljs.com/docs/formats -[Flutter]: https://github.com/flutter/flutter -[FlutterQuill]: https://pub.dev/packages/flutter_quill -[ReactQuill]: https://github.com/zenoamaro/react-quill +[Flutter]: https://github.com/flutter/flutter +[FlutterQuill]: https://pub.dev/packages/flutter_quill +[ReactQuill]: https://github.com/zenoamaro/react-quill [Slack Group]: https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g [Sample Page]: https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart From 30a9747b1d0ba41b2544b6b65bac4b78fdb2d5fa Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Mon, 24 May 2021 22:38:08 +0200 Subject: [PATCH 30/45] Fix buttons which ignore toolbariconsize Closes #189. --- lib/src/models/rules/insert.dart | 4 +- lib/src/widgets/controller.dart | 32 +- lib/src/widgets/toolbar.dart | 1142 ++--------------- .../widgets/toolbar/clear_format_button.dart | 42 + lib/src/widgets/toolbar/color_button.dart | 153 +++ lib/src/widgets/toolbar/history_button.dart | 78 ++ lib/src/widgets/toolbar/image_button.dart | 117 ++ lib/src/widgets/toolbar/indent_button.dart | 61 + .../widgets/toolbar/insert_embed_button.dart | 41 + .../widgets/toolbar/link_style_button.dart | 122 ++ .../toolbar/quill_dropdown_button.dart | 96 ++ .../widgets/toolbar/quill_icon_button.dart | 37 + .../toolbar/select_header_style_button.dart | 122 ++ .../toolbar/toggle_check_list_button.dart | 104 ++ .../widgets/toolbar/toggle_style_button.dart | 139 ++ 15 files changed, 1224 insertions(+), 1066 deletions(-) create mode 100644 lib/src/widgets/toolbar/clear_format_button.dart create mode 100644 lib/src/widgets/toolbar/color_button.dart create mode 100644 lib/src/widgets/toolbar/history_button.dart create mode 100644 lib/src/widgets/toolbar/image_button.dart create mode 100644 lib/src/widgets/toolbar/indent_button.dart create mode 100644 lib/src/widgets/toolbar/insert_embed_button.dart create mode 100644 lib/src/widgets/toolbar/link_style_button.dart create mode 100644 lib/src/widgets/toolbar/quill_dropdown_button.dart create mode 100644 lib/src/widgets/toolbar/quill_icon_button.dart create mode 100644 lib/src/widgets/toolbar/select_header_style_button.dart create mode 100644 lib/src/widgets/toolbar/toggle_check_list_button.dart create mode 100644 lib/src/widgets/toolbar/toggle_style_button.dart diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index f50be23f..60211ab2 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -188,8 +188,8 @@ class AutoExitBlockRule extends InsertRule { // Here we now know that the line after `cur` is not in the same block // therefore we can exit this block. final attributes = cur.attributes ?? {}; - final k = attributes.keys - .firstWhere(Attribute.blockKeysExceptHeader.contains); + final k = + attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains); attributes[k] = null; // retain(1) should be '\n', set it with no attribute return Delta()..retain(index + (len ?? 0))..retain(1, attributes); diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index 9edf805b..7de1a122 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -11,11 +11,10 @@ import '../models/quill_delta.dart'; import '../utils/diff_delta.dart'; class QuillController extends ChangeNotifier { - QuillController( - {required this.document, - required this.selection, - this.iconSize = 18, - this.toolbarHeightFactor = 2}); + QuillController({ + required this.document, + required TextSelection selection, + }) : _selection = selection; factory QuillController.basic() { return QuillController( @@ -24,19 +23,24 @@ class QuillController extends ChangeNotifier { ); } + /// Document managed by this controller. final Document document; - TextSelection selection; - double iconSize; - double toolbarHeightFactor; + /// Currently selected text within the [document]. + TextSelection get selection => _selection; + TextSelection _selection; + + /// Store any styles attribute that got toggled by the tap of a button + /// and that has not been applied yet. + /// It gets reset after each format action within the [document]. Style toggledStyle = Style(); + bool ignoreFocusOnTextChange = false; - /// Controls whether this [QuillController] instance has already been disposed - /// of + /// True when this [QuillController] instance has been disposed. /// - /// This is a safe approach to make sure that listeners don't crash when - /// adding, removing or listeners to this instance. + /// A safety mechanism to ensure that listeners don't crash when adding, + /// removing or listeners to this instance. bool _isDisposed = false; // item1: Document state before [change]. @@ -220,9 +224,9 @@ class QuillController extends ChangeNotifier { } void _updateSelection(TextSelection textSelection, ChangeSource source) { - selection = textSelection; + _selection = textSelection; final end = document.length - 1; - selection = selection.copyWith( + _selection = selection.copyWith( baseOffset: math.min(selection.baseOffset, end), extentOffset: math.min(selection.extentOffset, end)); } diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index c5fe2bab..d4d00f5d 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -1,921 +1,54 @@ import 'dart:io'; -import 'package:file_picker/file_picker.dart'; -import 'package:filesystem_picker/filesystem_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:path_provider/path_provider.dart'; import '../models/documents/attribute.dart'; -import '../models/documents/nodes/embed.dart'; -import '../models/documents/style.dart'; -import '../utils/color.dart'; import 'controller.dart'; +import 'toolbar/clear_format_button.dart'; +import 'toolbar/color_button.dart'; +import 'toolbar/history_button.dart'; +import 'toolbar/image_button.dart'; +import 'toolbar/indent_button.dart'; +import 'toolbar/insert_embed_button.dart'; +import 'toolbar/link_style_button.dart'; +import 'toolbar/select_header_style_button.dart'; +import 'toolbar/toggle_check_list_button.dart'; +import 'toolbar/toggle_style_button.dart'; + +export 'toolbar/clear_format_button.dart'; +export 'toolbar/color_button.dart'; +export 'toolbar/history_button.dart'; +export 'toolbar/image_button.dart'; +export 'toolbar/indent_button.dart'; +export 'toolbar/insert_embed_button.dart'; +export 'toolbar/link_style_button.dart'; +export 'toolbar/quill_dropdown_button.dart'; +export 'toolbar/quill_icon_button.dart'; +export 'toolbar/select_header_style_button.dart'; +export 'toolbar/toggle_check_list_button.dart'; +export 'toolbar/toggle_style_button.dart'; typedef OnImagePickCallback = Future Function(File file); typedef ImagePickImpl = Future Function(ImageSource source); -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) { - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: controller.iconSize * 1.77, - icon: Icon( - icon, - size: controller.iconSize, - color: Theme.of(context).iconTheme.color, - ), - fillColor: fillColor ?? Theme.of(context).canvasColor, - onPressed: () { - final index = controller.selection.baseOffset; - final length = controller.selection.extentOffset - index; - controller.replaceText(index, length, BlockEmbed.horizontalRule, null); - }, - ); - } -} - -class LinkStyleButton extends StatefulWidget { - const LinkStyleButton({ - required this.controller, - this.icon, - Key? key, - }) : super(key: key); - - final QuillController controller; - final IconData? icon; - - @override - _LinkStyleButtonState createState() => _LinkStyleButtonState(); -} - -class _LinkStyleButtonState extends State { - void _didChangeSelection() { - setState(() {}); - } - - @override - void initState() { - super.initState(); - widget.controller.addListener(_didChangeSelection); - } - - @override - void didUpdateWidget(covariant LinkStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeSelection); - widget.controller.addListener(_didChangeSelection); - } - } - - @override - void dispose() { - super.dispose(); - widget.controller.removeListener(_didChangeSelection); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEnabled = !widget.controller.selection.isCollapsed; - final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon( - widget.icon ?? Icons.link, - size: widget.controller.iconSize, - color: isEnabled ? theme.iconTheme.color : theme.disabledColor, - ), - fillColor: Theme.of(context).canvasColor, - onPressed: pressedHandler, - ); - } - - void _openLinkDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) { - return const _LinkDialog(); - }, - ).then(_linkSubmitted); - } - - void _linkSubmitted(String? value) { - if (value == null || value.isEmpty) { - return; - } - widget.controller.formatSelection(LinkAttribute(value)); - } -} - -class _LinkDialog extends StatefulWidget { - const _LinkDialog({Key? key}) : super(key: key); - - @override - _LinkDialogState createState() => _LinkDialogState(); -} - -class _LinkDialogState extends State<_LinkDialog> { - String _link = ''; - - @override - Widget build(BuildContext context) { - return AlertDialog( - content: TextField( - decoration: const InputDecoration(labelText: 'Paste a link'), - autofocus: true, - onChanged: _linkChanged, - ), - actions: [ - TextButton( - onPressed: _link.isNotEmpty ? _applyLink : null, - child: const Text('Apply'), - ), - ], - ); - } - - void _linkChanged(String value) { - setState(() { - _link = value; - }); - } - - void _applyLink() { - Navigator.pop(context, _link); - } -} - -typedef ToggleStyleButtonBuilder = Widget Function( - BuildContext context, - Attribute attribute, - IconData icon, - Color? fillColor, - bool? isToggled, - VoidCallback? onPressed, -); - -class ToggleStyleButton extends StatefulWidget { - const ToggleStyleButton({ - required this.attribute, - required this.icon, - required this.controller, - this.fillColor, - this.childBuilder = defaultToggleStyleButtonBuilder, - Key? key, - }) : super(key: key); - - final Attribute attribute; - - final IconData icon; - - final Color? fillColor; - - final QuillController controller; - - final ToggleStyleButtonBuilder childBuilder; - - @override - _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); -} - -class _ToggleStyleButtonState extends State { - bool? _isToggled; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggled = - _getIsToggled(widget.controller.getSelectionStyle().attributes); - }); - } - - @override - void initState() { - super.initState(); - _isToggled = _getIsToggled(_selectionStyle.attributes); - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { - final attribute = attrs[widget.attribute.key]; - if (attribute == null) { - return false; - } - return attribute.value == widget.attribute.value; - } - return attrs.containsKey(widget.attribute.key); - } - - @override - void didUpdateWidget(covariant ToggleStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggled = _getIsToggled(_selectionStyle.attributes); - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isInCodeBlock = - _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); - final isEnabled = - !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; - return widget.childBuilder(context, widget.attribute, widget.icon, - widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); - } - - void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! - ? Attribute.clone(widget.attribute, null) - : widget.attribute); - } -} - -class ToggleCheckListButton extends StatefulWidget { - const ToggleCheckListButton({ - 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; - - final Attribute attribute; - - @override - _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); -} - -class _ToggleCheckListButtonState extends State { - bool? _isToggled; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggled = - _getIsToggled(widget.controller.getSelectionStyle().attributes); - }); - } - - @override - void initState() { - super.initState(); - _isToggled = _getIsToggled(_selectionStyle.attributes); - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { - final attribute = attrs[widget.attribute.key]; - if (attribute == null) { - return false; - } - return attribute.value == widget.attribute.value || - attribute.value == Attribute.checked.value; - } - return attrs.containsKey(widget.attribute.key); - } - - @override - void didUpdateWidget(covariant ToggleCheckListButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggled = _getIsToggled(_selectionStyle.attributes); - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isInCodeBlock = - _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); - final isEnabled = - !isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; - return widget.childBuilder(context, Attribute.unchecked, widget.icon, - widget.fillColor, _isToggled, isEnabled ? _toggleAttribute : null); - } - - void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! - ? Attribute.clone(Attribute.unchecked, null) - : Attribute.unchecked); - } -} - -Widget defaultToggleStyleButtonBuilder( - BuildContext context, - Attribute attribute, - IconData icon, - Color? fillColor, - bool? isToggled, - VoidCallback? onPressed, -) { - final theme = Theme.of(context); - final isEnabled = onPressed != null; - final iconColor = isEnabled - ? isToggled == true - ? theme.primaryIconTheme.color - : theme.iconTheme.color - : theme.disabledColor; - final fill = isToggled == true - ? theme.toggleableActiveColor - : fillColor ?? theme.canvasColor; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: 18 * 1.77, - icon: Icon(icon, size: 18, color: iconColor), - fillColor: fill, - onPressed: onPressed, - ); -} - -class SelectHeaderStyleButton extends StatefulWidget { - const SelectHeaderStyleButton({required this.controller, Key? key}) - : super(key: key); - - final QuillController controller; - - @override - _SelectHeaderStyleButtonState createState() => - _SelectHeaderStyleButtonState(); -} - -class _SelectHeaderStyleButtonState extends State { - Attribute? _value; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - }); - } - - void _selectAttribute(value) { - widget.controller.formatSelection(value); - } - - @override - void initState() { - super.initState(); - setState(() { - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - }); - widget.controller.addListener(_didChangeEditingValue); - } - - @override - void didUpdateWidget(covariant SelectHeaderStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _selectHeadingStyleButtonBuilder( - context, _value, _selectAttribute, widget.controller.iconSize); - } -} - -Widget _selectHeadingStyleButtonBuilder(BuildContext context, Attribute? value, - ValueChanged onSelected, double iconSize) { - final _valueToText = { - Attribute.header: 'N', - Attribute.h1: 'H1', - Attribute.h2: 'H2', - Attribute.h3: 'H3', - }; - - final _valueAttribute = [ - Attribute.header, - Attribute.h1, - Attribute.h2, - Attribute.h3 - ]; - final _valueString = ['N', 'H1', 'H2', 'H3']; - - final theme = Theme.of(context); - final style = TextStyle( - fontWeight: FontWeight.w600, - fontSize: iconSize * 0.7, - ); - - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(4, (index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), - child: ConstrainedBox( - constraints: BoxConstraints.tightFor( - width: iconSize * 1.77, - height: iconSize * 1.77, - ), - child: RawMaterialButton( - hoverElevation: 0, - highlightElevation: 0, - elevation: 0, - visualDensity: VisualDensity.compact, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - fillColor: _valueToText[value] == _valueString[index] - ? theme.toggleableActiveColor - : theme.canvasColor, - onPressed: () { - onSelected(_valueAttribute[index]); - }, - child: Text( - _valueString[index], - style: style.copyWith( - color: _valueToText[value] == _valueString[index] - ? theme.primaryIconTheme.color - : theme.iconTheme.color, - ), - ), - ), - ), - ); - }), - ); -} - -class ImageButton extends StatefulWidget { - const ImageButton({ - required this.icon, - required this.controller, - required this.imageSource, - this.onImagePickCallback, - this.imagePickImpl, - Key? key, - }) : super(key: key); - - final IconData icon; - - final QuillController controller; +// The default size of the icon of a button. +const double kDefaultIconSize = 18; - final OnImagePickCallback? onImagePickCallback; +// The factor of how much larger the button is in relation to the icon. +const double kIconButtonFactor = 1.77; - final ImagePickImpl? imagePickImpl; - - final ImageSource imageSource; - - @override - _ImageButtonState createState() => _ImageButtonState(); -} - -class _ImageButtonState extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return QuillIconButton( - icon: Icon( - widget.icon, - size: widget.controller.iconSize, - color: theme.iconTheme.color, - ), - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - fillColor: theme.canvasColor, - onPressed: _handleImageButtonTap, - ); - } - - Future _handleImageButtonTap() async { - final index = widget.controller.selection.baseOffset; - final length = widget.controller.selection.extentOffset - index; - - String? imageUrl; - if (widget.imagePickImpl != null) { - imageUrl = await widget.imagePickImpl!(widget.imageSource); - } else { - if (kIsWeb) { - imageUrl = await _pickImageWeb(); - } else if (Platform.isAndroid || Platform.isIOS) { - imageUrl = await _pickImage(widget.imageSource); - } else { - imageUrl = await _pickImageDesktop(); - } - } - - if (imageUrl != null) { - widget.controller - .replaceText(index, length, BlockEmbed.image(imageUrl), null); - } - } - - Future _pickImageWeb() async { - final result = await FilePicker.platform.pickFiles(); - if (result == null) { - return null; - } - - // Take first, because we don't allow picking multiple files. - final fileName = result.files.first.name!; - final file = File(fileName); - - return widget.onImagePickCallback!(file); - } - - Future _pickImage(ImageSource source) async { - final pickedFile = await ImagePicker().getImage(source: source); - if (pickedFile == null) { - return null; - } - - return widget.onImagePickCallback!(File(pickedFile.path)); - } - - Future _pickImageDesktop() async { - final filePath = await FilesystemPicker.open( - context: context, - rootDirectory: await getApplicationDocumentsDirectory(), - fsType: FilesystemType.file, - fileTileSelectMode: FileTileSelectMode.wholeTile, - ); - if (filePath == null || filePath.isEmpty) return null; - - final file = File(filePath); - return widget.onImagePickCallback!(file); - } -} - -/// Controls color styles. -/// -/// When pressed, this button displays overlay toolbar with -/// buttons for each color. -class ColorButton extends StatefulWidget { - const ColorButton({ - required this.icon, - required this.controller, - required this.background, - Key? key, - }) : super(key: key); - - final IconData icon; - final bool background; - final QuillController controller; - - @override - _ColorButtonState createState() => _ColorButtonState(); -} - -class _ColorButtonState extends State { - late bool _isToggledColor; - late bool _isToggledBackground; - late bool _isWhite; - late bool _isWhitebackground; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggledColor = - _getIsToggledColor(widget.controller.getSelectionStyle().attributes); - _isToggledBackground = _getIsToggledBackground( - widget.controller.getSelectionStyle().attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - }); - } - - @override - void initState() { - super.initState(); - _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggledColor(Map attrs) { - return attrs.containsKey(Attribute.color.key); - } - - bool _getIsToggledBackground(Map attrs) { - return attrs.containsKey(Attribute.background.key); - } - - @override - void didUpdateWidget(covariant ColorButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = - _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconColor = _isToggledColor && !widget.background && !_isWhite - ? stringToColor(_selectionStyle.attributes['color']!.value) - : theme.iconTheme.color; - - final iconColorBackground = - _isToggledBackground && widget.background && !_isWhitebackground - ? stringToColor(_selectionStyle.attributes['background']!.value) - : theme.iconTheme.color; - - final fillColor = _isToggledColor && !widget.background && _isWhite - ? stringToColor('#ffffff') - : theme.canvasColor; - final fillColorBackground = - _isToggledBackground && widget.background && _isWhitebackground - ? stringToColor('#ffffff') - : theme.canvasColor; - - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, - size: widget.controller.iconSize, - color: widget.background ? iconColorBackground : iconColor), - fillColor: widget.background ? fillColorBackground : fillColor, - onPressed: _showColorPicker, - ); - } - - void _changeColor(Color color) { - var hex = color.value.toRadixString(16); - if (hex.startsWith('ff')) { - hex = hex.substring(2); - } - hex = '#$hex'; - widget.controller.formatSelection( - widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); - Navigator.of(context).pop(); - } - - void _showColorPicker() { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Select Color'), - backgroundColor: Theme.of(context).canvasColor, - content: SingleChildScrollView( - child: MaterialPicker( - pickerColor: const Color(0x00000000), - onColorChanged: _changeColor, - ), - )), - ); - } -} - -class HistoryButton extends StatefulWidget { - const HistoryButton({ - required this.icon, - required this.controller, - required this.undo, - Key? key, - }) : super(key: key); - - final IconData icon; - final bool undo; - final QuillController controller; - - @override - _HistoryButtonState createState() => _HistoryButtonState(); -} - -class _HistoryButtonState extends State { - Color? _iconColor; - late ThemeData theme; - - @override - Widget build(BuildContext context) { - theme = Theme.of(context); - _setIconColor(); - - final fillColor = theme.canvasColor; - widget.controller.changes.listen((event) async { - _setIconColor(); - }); - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, - size: widget.controller.iconSize, color: _iconColor), - fillColor: fillColor, - onPressed: _changeHistory, - ); - } - - void _setIconColor() { - if (!mounted) return; - - if (widget.undo) { - setState(() { - _iconColor = widget.controller.hasUndo - ? theme.iconTheme.color - : theme.disabledColor; - }); - } else { - setState(() { - _iconColor = widget.controller.hasRedo - ? theme.iconTheme.color - : theme.disabledColor; - }); - } - } - - void _changeHistory() { - if (widget.undo) { - if (widget.controller.hasUndo) { - widget.controller.undo(); - } - } else { - if (widget.controller.hasRedo) { - widget.controller.redo(); - } - } - - _setIconColor(); - } -} - -class IndentButton extends StatefulWidget { - const IndentButton({ - required this.icon, - required this.controller, - required this.isIncrease, - Key? key, - }) : super(key: key); - - final IconData icon; - final QuillController controller; - final bool isIncrease; - - @override - _IndentButtonState createState() => _IndentButtonState(); -} - -class _IndentButtonState extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconColor = theme.iconTheme.color; - final fillColor = theme.canvasColor; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: - Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), - fillColor: fillColor, - onPressed: () { - final indent = widget.controller - .getSelectionStyle() - .attributes[Attribute.indent.key]; - if (indent == null) { - if (widget.isIncrease) { - widget.controller.formatSelection(Attribute.indentL1); - } - return; - } - if (indent.value == 1 && !widget.isIncrease) { - widget.controller - .formatSelection(Attribute.clone(Attribute.indentL1, null)); - return; - } - if (widget.isIncrease) { - widget.controller - .formatSelection(Attribute.getIndentLevel(indent.value + 1)); - return; - } - widget.controller - .formatSelection(Attribute.getIndentLevel(indent.value - 1)); - }, - ); - } -} - -class ClearFormatButton extends StatefulWidget { - const ClearFormatButton({ - required this.icon, - required this.controller, +class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { + const QuillToolbar({ + required this.children, + this.toolBarHeight = 36, Key? key, }) : super(key: key); - final IconData icon; - - final QuillController controller; - - @override - _ClearFormatButtonState createState() => _ClearFormatButtonState(); -} - -class _ClearFormatButtonState extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final iconColor = theme.iconTheme.color; - final fillColor = theme.canvasColor; - return QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, - size: widget.controller.iconSize, color: iconColor), - fillColor: fillColor, - onPressed: () { - for (final k - in widget.controller.getSelectionStyle().attributes.values) { - widget.controller.formatSelection(Attribute.clone(k, null)); - } - }); - } -} - -class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { - const QuillToolbar( - {required this.children, this.toolBarHeight = 36, Key? key}) - : super(key: key); - factory QuillToolbar.basic({ required QuillController controller, - double toolbarIconSize = 18.0, + double toolbarIconSize = kDefaultIconSize, bool showBoldButton = true, bool showItalicButton = true, bool showUnderLineButton = true, @@ -936,16 +69,15 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { OnImagePickCallback? onImagePickCallback, Key? key, }) { - controller.iconSize = toolbarIconSize; - return QuillToolbar( key: key, - toolBarHeight: toolbarIconSize * controller.toolbarHeightFactor, + toolBarHeight: toolbarIconSize * 2, children: [ Visibility( visible: showHistory, child: HistoryButton( icon: Icons.undo_outlined, + iconSize: toolbarIconSize, controller: controller, undo: true, ), @@ -954,6 +86,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: showHistory, child: HistoryButton( icon: Icons.redo_outlined, + iconSize: toolbarIconSize, controller: controller, undo: false, ), @@ -964,6 +97,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { child: ToggleStyleButton( attribute: Attribute.bold, icon: Icons.format_bold, + iconSize: toolbarIconSize, controller: controller, ), ), @@ -973,6 +107,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { child: ToggleStyleButton( attribute: Attribute.italic, icon: Icons.format_italic, + iconSize: toolbarIconSize, controller: controller, ), ), @@ -982,6 +117,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { child: ToggleStyleButton( attribute: Attribute.underline, icon: Icons.format_underline, + iconSize: toolbarIconSize, controller: controller, ), ), @@ -991,6 +127,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { child: ToggleStyleButton( attribute: Attribute.strikeThrough, icon: Icons.format_strikethrough, + iconSize: toolbarIconSize, controller: controller, ), ), @@ -999,6 +136,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: showColorButton, child: ColorButton( icon: Icons.color_lens, + iconSize: toolbarIconSize, controller: controller, background: false, ), @@ -1008,6 +146,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: showBackgroundColorButton, child: ColorButton( icon: Icons.format_color_fill, + iconSize: toolbarIconSize, controller: controller, background: true, ), @@ -1017,6 +156,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: showClearFormat, child: ClearFormatButton( icon: Icons.format_clear, + iconSize: toolbarIconSize, controller: controller, ), ), @@ -1025,6 +165,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: onImagePickCallback != null, child: ImageButton( icon: Icons.image, + iconSize: toolbarIconSize, controller: controller, imageSource: ImageSource.gallery, onImagePickCallback: onImagePickCallback, @@ -1035,26 +176,39 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: onImagePickCallback != null, child: ImageButton( icon: Icons.photo_camera, + iconSize: toolbarIconSize, controller: controller, imageSource: ImageSource.camera, onImagePickCallback: onImagePickCallback, ), ), Visibility( - visible: showHeaderStyle, - child: VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400)), + visible: showHeaderStyle, + child: VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, + ), + ), Visibility( - visible: showHeaderStyle, - child: SelectHeaderStyleButton(controller: controller)), + visible: showHeaderStyle, + child: SelectHeaderStyleButton( + controller: controller, + iconSize: toolbarIconSize, + ), + ), VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400), + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, + ), Visibility( visible: showListNumbers, child: ToggleStyleButton( attribute: Attribute.ol, controller: controller, icon: Icons.format_list_numbered, + iconSize: toolbarIconSize, ), ), Visibility( @@ -1063,6 +217,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { attribute: Attribute.ul, controller: controller, icon: Icons.format_list_bulleted, + iconSize: toolbarIconSize, ), ), Visibility( @@ -1071,6 +226,7 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { attribute: Attribute.unchecked, controller: controller, icon: Icons.check_box, + iconSize: toolbarIconSize, ), ), Visibility( @@ -1079,27 +235,34 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { attribute: Attribute.codeBlock, controller: controller, icon: Icons.code, + iconSize: toolbarIconSize, ), ), Visibility( - visible: !showListNumbers && - !showListBullets && - !showListCheck && - !showCodeBlock, - child: VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400)), + visible: !showListNumbers && + !showListBullets && + !showListCheck && + !showCodeBlock, + child: VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, + ), + ), Visibility( visible: showQuote, child: ToggleStyleButton( attribute: Attribute.blockQuote, controller: controller, icon: Icons.format_quote, + iconSize: toolbarIconSize, ), ), Visibility( visible: showIndent, child: IndentButton( icon: Icons.format_indent_increase, + iconSize: toolbarIconSize, controller: controller, isIncrease: true, ), @@ -1108,22 +271,32 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { visible: showIndent, child: IndentButton( icon: Icons.format_indent_decrease, + iconSize: toolbarIconSize, controller: controller, isIncrease: false, ), ), Visibility( - visible: showQuote, - child: VerticalDivider( - indent: 12, endIndent: 12, color: Colors.grey.shade400)), + visible: showQuote, + child: VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, + ), + ), Visibility( - visible: showLink, - child: LinkStyleButton(controller: controller)), + visible: showLink, + child: LinkStyleButton( + controller: controller, + iconSize: toolbarIconSize, + ), + ), Visibility( visible: showHorizontalRule, child: InsertEmbedButton( controller: controller, icon: Icons.horizontal_rule, + iconSize: toolbarIconSize, ), ), ]); @@ -1161,134 +334,3 @@ class _QuillToolbarState extends State { ); } } - -class QuillIconButton extends StatelessWidget { - const QuillIconButton({ - required this.onPressed, - this.icon, - this.size = 40, - this.fillColor, - this.hoverElevation = 1, - this.highlightElevation = 1, - Key? key, - }) : super(key: key); - - final VoidCallback? onPressed; - final Widget? icon; - final double size; - final Color? fillColor; - final double hoverElevation; - final double highlightElevation; - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints.tightFor(width: size, height: size), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - fillColor: fillColor, - elevation: 0, - hoverElevation: hoverElevation, - highlightElevation: hoverElevation, - onPressed: onPressed, - child: icon, - ), - ); - } -} - -class QuillDropdownButton extends StatefulWidget { - const QuillDropdownButton({ - required this.child, - required this.initialValue, - required this.items, - required this.onSelected, - this.height = 40, - this.fillColor, - this.hoverElevation = 1, - this.highlightElevation = 1, - Key? key, - }) : super(key: key); - - final double height; - final Color? fillColor; - final double hoverElevation; - final double highlightElevation; - final Widget child; - final T initialValue; - final List> items; - final ValueChanged onSelected; - - @override - _QuillDropdownButtonState createState() => _QuillDropdownButtonState(); -} - -class _QuillDropdownButtonState extends State> { - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints.tightFor(height: widget.height), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - fillColor: widget.fillColor, - elevation: 0, - hoverElevation: widget.hoverElevation, - highlightElevation: widget.hoverElevation, - onPressed: _showMenu, - child: _buildContent(context), - ), - ); - } - - void _showMenu() { - final popupMenuTheme = PopupMenuTheme.of(context); - final button = context.findRenderObject() as RenderBox; - final overlay = - Overlay.of(context)!.context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal(button.size.bottomLeft(Offset.zero), - ancestor: overlay), - ), - Offset.zero & overlay.size, - ); - showMenu( - context: context, - elevation: 4, - // widget.elevation ?? popupMenuTheme.elevation, - initialValue: widget.initialValue, - items: widget.items, - position: position, - shape: popupMenuTheme.shape, - // widget.shape ?? popupMenuTheme.shape, - color: popupMenuTheme.color, // widget.color ?? popupMenuTheme.color, - // captureInheritedThemes: widget.captureInheritedThemes, - ).then((newValue) { - if (!mounted) return null; - if (newValue == null) { - // if (widget.onCanceled != null) widget.onCanceled(); - return null; - } - widget.onSelected(newValue); - }); - } - - Widget _buildContent(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints.tightFor(width: 110), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - widget.child, - Expanded(child: Container()), - const Icon(Icons.arrow_drop_down, size: 15) - ], - ), - ), - ); - } -} diff --git a/lib/src/widgets/toolbar/clear_format_button.dart b/lib/src/widgets/toolbar/clear_format_button.dart new file mode 100644 index 00000000..d55c21df --- /dev/null +++ b/lib/src/widgets/toolbar/clear_format_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import '../../../flutter_quill.dart'; +import 'quill_icon_button.dart'; + +class ClearFormatButton extends StatefulWidget { + const ClearFormatButton({ + required this.icon, + required this.controller, + this.iconSize = kDefaultIconSize, + Key? key, + }) : super(key: key); + + final IconData icon; + final double iconSize; + + final QuillController controller; + + @override + _ClearFormatButtonState createState() => _ClearFormatButtonState(); +} + +class _ClearFormatButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = theme.iconTheme.color; + final fillColor = theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * kIconButtonFactor, + icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), + fillColor: fillColor, + onPressed: () { + for (final k + in widget.controller.getSelectionStyle().attributes.values) { + widget.controller.formatSelection(Attribute.clone(k, null)); + } + }); + } +} diff --git a/lib/src/widgets/toolbar/color_button.dart b/lib/src/widgets/toolbar/color_button.dart new file mode 100644 index 00000000..fa5bb520 --- /dev/null +++ b/lib/src/widgets/toolbar/color_button.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/style.dart'; +import '../../utils/color.dart'; +import '../controller.dart'; +import '../toolbar.dart'; +import 'quill_icon_button.dart'; + +/// Controls color styles. +/// +/// When pressed, this button displays overlay toolbar with +/// buttons for each color. +class ColorButton extends StatefulWidget { + const ColorButton({ + required this.icon, + required this.controller, + required this.background, + this.iconSize = kDefaultIconSize, + Key? key, + }) : super(key: key); + + final IconData icon; + final double iconSize; + final bool background; + final QuillController controller; + + @override + _ColorButtonState createState() => _ColorButtonState(); +} + +class _ColorButtonState extends State { + late bool _isToggledColor; + late bool _isToggledBackground; + late bool _isWhite; + late bool _isWhitebackground; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _isToggledColor = + _getIsToggledColor(widget.controller.getSelectionStyle().attributes); + _isToggledBackground = _getIsToggledBackground( + widget.controller.getSelectionStyle().attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; + }); + } + + @override + void initState() { + super.initState(); + _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); + _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; + widget.controller.addListener(_didChangeEditingValue); + } + + bool _getIsToggledColor(Map attrs) { + return attrs.containsKey(Attribute.color.key); + } + + bool _getIsToggledBackground(Map attrs) { + return attrs.containsKey(Attribute.background.key); + } + + @override + void didUpdateWidget(covariant ColorButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); + _isToggledBackground = + _getIsToggledBackground(_selectionStyle.attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = _isToggledColor && !widget.background && !_isWhite + ? stringToColor(_selectionStyle.attributes['color']!.value) + : theme.iconTheme.color; + + final iconColorBackground = + _isToggledBackground && widget.background && !_isWhitebackground + ? stringToColor(_selectionStyle.attributes['background']!.value) + : theme.iconTheme.color; + + final fillColor = _isToggledColor && !widget.background && _isWhite + ? stringToColor('#ffffff') + : theme.canvasColor; + final fillColorBackground = + _isToggledBackground && widget.background && _isWhitebackground + ? stringToColor('#ffffff') + : theme.canvasColor; + + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * kIconButtonFactor, + icon: Icon(widget.icon, + size: widget.iconSize, + color: widget.background ? iconColorBackground : iconColor), + fillColor: widget.background ? fillColorBackground : fillColor, + onPressed: _showColorPicker, + ); + } + + void _changeColor(Color color) { + var hex = color.value.toRadixString(16); + if (hex.startsWith('ff')) { + hex = hex.substring(2); + } + hex = '#$hex'; + widget.controller.formatSelection( + widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); + Navigator.of(context).pop(); + } + + void _showColorPicker() { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Select Color'), + backgroundColor: Theme.of(context).canvasColor, + content: SingleChildScrollView( + child: MaterialPicker( + pickerColor: const Color(0x00000000), + onColorChanged: _changeColor, + ), + )), + ); + } +} diff --git a/lib/src/widgets/toolbar/history_button.dart b/lib/src/widgets/toolbar/history_button.dart new file mode 100644 index 00000000..2ed794c5 --- /dev/null +++ b/lib/src/widgets/toolbar/history_button.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../../../flutter_quill.dart'; +import 'quill_icon_button.dart'; + +class HistoryButton extends StatefulWidget { + const HistoryButton({ + required this.icon, + required this.controller, + required this.undo, + this.iconSize = kDefaultIconSize, + Key? key, + }) : super(key: key); + + final IconData icon; + final double iconSize; + final bool undo; + final QuillController controller; + + @override + _HistoryButtonState createState() => _HistoryButtonState(); +} + +class _HistoryButtonState extends State { + Color? _iconColor; + late ThemeData theme; + + @override + Widget build(BuildContext context) { + theme = Theme.of(context); + _setIconColor(); + + final fillColor = theme.canvasColor; + widget.controller.changes.listen((event) async { + _setIconColor(); + }); + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * 1.77, + icon: Icon(widget.icon, size: widget.iconSize, color: _iconColor), + fillColor: fillColor, + onPressed: _changeHistory, + ); + } + + void _setIconColor() { + if (!mounted) return; + + if (widget.undo) { + setState(() { + _iconColor = widget.controller.hasUndo + ? theme.iconTheme.color + : theme.disabledColor; + }); + } else { + setState(() { + _iconColor = widget.controller.hasRedo + ? theme.iconTheme.color + : theme.disabledColor; + }); + } + } + + void _changeHistory() { + if (widget.undo) { + if (widget.controller.hasUndo) { + widget.controller.undo(); + } + } else { + if (widget.controller.hasRedo) { + widget.controller.redo(); + } + } + + _setIconColor(); + } +} diff --git a/lib/src/widgets/toolbar/image_button.dart b/lib/src/widgets/toolbar/image_button.dart new file mode 100644 index 00000000..33e191a9 --- /dev/null +++ b/lib/src/widgets/toolbar/image_button.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:filesystem_picker/filesystem_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../models/documents/nodes/embed.dart'; +import '../controller.dart'; +import '../toolbar.dart'; +import 'quill_icon_button.dart'; + +class ImageButton extends StatefulWidget { + const ImageButton({ + required this.icon, + required this.controller, + required this.imageSource, + this.iconSize = kDefaultIconSize, + this.onImagePickCallback, + this.imagePickImpl, + Key? key, + }) : super(key: key); + + final IconData icon; + final double iconSize; + + final QuillController controller; + + final OnImagePickCallback? onImagePickCallback; + + final ImagePickImpl? imagePickImpl; + + final ImageSource imageSource; + + @override + _ImageButtonState createState() => _ImageButtonState(); +} + +class _ImageButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return QuillIconButton( + icon: Icon( + widget.icon, + size: widget.iconSize, + color: theme.iconTheme.color, + ), + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * 1.77, + fillColor: theme.canvasColor, + onPressed: _handleImageButtonTap, + ); + } + + Future _handleImageButtonTap() async { + final index = widget.controller.selection.baseOffset; + final length = widget.controller.selection.extentOffset - index; + + String? imageUrl; + if (widget.imagePickImpl != null) { + imageUrl = await widget.imagePickImpl!(widget.imageSource); + } else { + if (kIsWeb) { + imageUrl = await _pickImageWeb(); + } else if (Platform.isAndroid || Platform.isIOS) { + imageUrl = await _pickImage(widget.imageSource); + } else { + imageUrl = await _pickImageDesktop(); + } + } + + if (imageUrl != null) { + widget.controller + .replaceText(index, length, BlockEmbed.image(imageUrl), null); + } + } + + Future _pickImageWeb() async { + final result = await FilePicker.platform.pickFiles(); + if (result == null) { + return null; + } + + // Take first, because we don't allow picking multiple files. + final fileName = result.files.first.name!; + final file = File(fileName); + + return widget.onImagePickCallback!(file); + } + + Future _pickImage(ImageSource source) async { + final pickedFile = await ImagePicker().getImage(source: source); + if (pickedFile == null) { + return null; + } + + return widget.onImagePickCallback!(File(pickedFile.path)); + } + + Future _pickImageDesktop() async { + final filePath = await FilesystemPicker.open( + context: context, + rootDirectory: await getApplicationDocumentsDirectory(), + fsType: FilesystemType.file, + fileTileSelectMode: FileTileSelectMode.wholeTile, + ); + if (filePath == null || filePath.isEmpty) return null; + + final file = File(filePath); + return widget.onImagePickCallback!(file); + } +} diff --git a/lib/src/widgets/toolbar/indent_button.dart b/lib/src/widgets/toolbar/indent_button.dart new file mode 100644 index 00000000..aa6dfadb --- /dev/null +++ b/lib/src/widgets/toolbar/indent_button.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../../../flutter_quill.dart'; +import 'quill_icon_button.dart'; + +class IndentButton extends StatefulWidget { + const IndentButton({ + required this.icon, + required this.controller, + required this.isIncrease, + this.iconSize = kDefaultIconSize, + Key? key, + }) : super(key: key); + + final IconData icon; + final double iconSize; + final QuillController controller; + final bool isIncrease; + + @override + _IndentButtonState createState() => _IndentButtonState(); +} + +class _IndentButtonState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = theme.iconTheme.color; + final fillColor = theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * 1.77, + icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), + fillColor: fillColor, + onPressed: () { + final indent = widget.controller + .getSelectionStyle() + .attributes[Attribute.indent.key]; + if (indent == null) { + if (widget.isIncrease) { + widget.controller.formatSelection(Attribute.indentL1); + } + return; + } + if (indent.value == 1 && !widget.isIncrease) { + widget.controller + .formatSelection(Attribute.clone(Attribute.indentL1, null)); + return; + } + if (widget.isIncrease) { + widget.controller + .formatSelection(Attribute.getIndentLevel(indent.value + 1)); + return; + } + widget.controller + .formatSelection(Attribute.getIndentLevel(indent.value - 1)); + }, + ); + } +} diff --git a/lib/src/widgets/toolbar/insert_embed_button.dart b/lib/src/widgets/toolbar/insert_embed_button.dart new file mode 100644 index 00000000..5c889b69 --- /dev/null +++ b/lib/src/widgets/toolbar/insert_embed_button.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../models/documents/nodes/embed.dart'; +import '../controller.dart'; +import '../toolbar.dart'; +import 'quill_icon_button.dart'; + +class InsertEmbedButton extends StatelessWidget { + const InsertEmbedButton({ + required this.controller, + required this.icon, + this.iconSize = kDefaultIconSize, + this.fillColor, + Key? key, + }) : super(key: key); + + final QuillController controller; + final IconData icon; + final double iconSize; + final Color? fillColor; + + @override + Widget build(BuildContext context) { + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: iconSize * kIconButtonFactor, + icon: Icon( + icon, + size: iconSize, + color: Theme.of(context).iconTheme.color, + ), + fillColor: fillColor ?? Theme.of(context).canvasColor, + onPressed: () { + final index = controller.selection.baseOffset; + final length = controller.selection.extentOffset - index; + controller.replaceText(index, length, BlockEmbed.horizontalRule, null); + }, + ); + } +} diff --git a/lib/src/widgets/toolbar/link_style_button.dart b/lib/src/widgets/toolbar/link_style_button.dart new file mode 100644 index 00000000..417a9972 --- /dev/null +++ b/lib/src/widgets/toolbar/link_style_button.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../controller.dart'; +import '../toolbar.dart'; +import 'quill_icon_button.dart'; + +class LinkStyleButton extends StatefulWidget { + const LinkStyleButton({ + required this.controller, + this.iconSize = kDefaultIconSize, + this.icon, + Key? key, + }) : super(key: key); + + final QuillController controller; + final IconData? icon; + final double iconSize; + + @override + _LinkStyleButtonState createState() => _LinkStyleButtonState(); +} + +class _LinkStyleButtonState extends State { + void _didChangeSelection() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_didChangeSelection); + } + + @override + void didUpdateWidget(covariant LinkStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeSelection); + widget.controller.addListener(_didChangeSelection); + } + } + + @override + void dispose() { + super.dispose(); + widget.controller.removeListener(_didChangeSelection); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEnabled = !widget.controller.selection.isCollapsed; + final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * kIconButtonFactor, + icon: Icon( + widget.icon ?? Icons.link, + size: widget.iconSize, + color: isEnabled ? theme.iconTheme.color : theme.disabledColor, + ), + fillColor: Theme.of(context).canvasColor, + onPressed: pressedHandler, + ); + } + + void _openLinkDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) { + return const _LinkDialog(); + }, + ).then(_linkSubmitted); + } + + void _linkSubmitted(String? value) { + if (value == null || value.isEmpty) { + return; + } + widget.controller.formatSelection(LinkAttribute(value)); + } +} + +class _LinkDialog extends StatefulWidget { + const _LinkDialog({Key? key}) : super(key: key); + + @override + _LinkDialogState createState() => _LinkDialogState(); +} + +class _LinkDialogState extends State<_LinkDialog> { + String _link = ''; + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: TextField( + decoration: const InputDecoration(labelText: 'Paste a link'), + autofocus: true, + onChanged: _linkChanged, + ), + actions: [ + TextButton( + onPressed: _link.isNotEmpty ? _applyLink : null, + child: const Text('Apply'), + ), + ], + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + void _applyLink() { + Navigator.pop(context, _link); + } +} diff --git a/lib/src/widgets/toolbar/quill_dropdown_button.dart b/lib/src/widgets/toolbar/quill_dropdown_button.dart new file mode 100644 index 00000000..be3ed092 --- /dev/null +++ b/lib/src/widgets/toolbar/quill_dropdown_button.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +class QuillDropdownButton extends StatefulWidget { + const QuillDropdownButton({ + required this.child, + required this.initialValue, + required this.items, + required this.onSelected, + this.height = 40, + this.fillColor, + this.hoverElevation = 1, + this.highlightElevation = 1, + Key? key, + }) : super(key: key); + + final double height; + final Color? fillColor; + final double hoverElevation; + final double highlightElevation; + final Widget child; + final T initialValue; + final List> items; + final ValueChanged onSelected; + + @override + _QuillDropdownButtonState createState() => _QuillDropdownButtonState(); +} + +class _QuillDropdownButtonState extends State> { + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(height: widget.height), + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + fillColor: widget.fillColor, + elevation: 0, + hoverElevation: widget.hoverElevation, + highlightElevation: widget.hoverElevation, + onPressed: _showMenu, + child: _buildContent(context), + ), + ); + } + + void _showMenu() { + final popupMenuTheme = PopupMenuTheme.of(context); + final button = context.findRenderObject() as RenderBox; + final overlay = + Overlay.of(context)!.context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomLeft(Offset.zero), + ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + showMenu( + context: context, + elevation: 4, + // widget.elevation ?? popupMenuTheme.elevation, + initialValue: widget.initialValue, + items: widget.items, + position: position, + shape: popupMenuTheme.shape, + // widget.shape ?? popupMenuTheme.shape, + color: popupMenuTheme.color, // widget.color ?? popupMenuTheme.color, + // captureInheritedThemes: widget.captureInheritedThemes, + ).then((newValue) { + if (!mounted) return null; + if (newValue == null) { + // if (widget.onCanceled != null) widget.onCanceled(); + return null; + } + widget.onSelected(newValue); + }); + } + + Widget _buildContent(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 110), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + widget.child, + Expanded(child: Container()), + const Icon(Icons.arrow_drop_down, size: 15) + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/toolbar/quill_icon_button.dart b/lib/src/widgets/toolbar/quill_icon_button.dart new file mode 100644 index 00000000..0ffd3ef7 --- /dev/null +++ b/lib/src/widgets/toolbar/quill_icon_button.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class QuillIconButton extends StatelessWidget { + const QuillIconButton({ + required this.onPressed, + this.icon, + this.size = 40, + this.fillColor, + this.hoverElevation = 1, + this.highlightElevation = 1, + Key? key, + }) : super(key: key); + + final VoidCallback? onPressed; + final Widget? icon; + final double size; + final Color? fillColor; + final double hoverElevation; + final double highlightElevation; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(width: size, height: size), + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + fillColor: fillColor, + elevation: 0, + hoverElevation: hoverElevation, + highlightElevation: hoverElevation, + onPressed: onPressed, + child: icon, + ), + ); + } +} diff --git a/lib/src/widgets/toolbar/select_header_style_button.dart b/lib/src/widgets/toolbar/select_header_style_button.dart new file mode 100644 index 00000000..715e3632 --- /dev/null +++ b/lib/src/widgets/toolbar/select_header_style_button.dart @@ -0,0 +1,122 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/style.dart'; +import '../controller.dart'; +import '../toolbar.dart'; + +class SelectHeaderStyleButton extends StatefulWidget { + const SelectHeaderStyleButton({ + required this.controller, + this.iconSize = kDefaultIconSize, + Key? key, + }) : super(key: key); + + final QuillController controller; + final double iconSize; + + @override + _SelectHeaderStyleButtonState createState() => + _SelectHeaderStyleButtonState(); +} + +class _SelectHeaderStyleButtonState extends State { + Attribute? _value; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + @override + void initState() { + super.initState(); + setState(() { + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + }); + widget.controller.addListener(_didChangeEditingValue); + } + + @override + Widget build(BuildContext context) { + final _valueToText = { + Attribute.header: 'N', + Attribute.h1: 'H1', + Attribute.h2: 'H2', + Attribute.h3: 'H3', + }; + + final _valueAttribute = [ + Attribute.header, + Attribute.h1, + Attribute.h2, + Attribute.h3 + ]; + final _valueString = ['N', 'H1', 'H2', 'H3']; + + final theme = Theme.of(context); + final style = TextStyle( + fontWeight: FontWeight.w600, + fontSize: widget.iconSize * 0.7, + ); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(4, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: widget.iconSize * kIconButtonFactor, + height: widget.iconSize * kIconButtonFactor, + ), + child: RawMaterialButton( + hoverElevation: 0, + highlightElevation: 0, + elevation: 0, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2)), + fillColor: _valueToText[_value] == _valueString[index] + ? theme.toggleableActiveColor + : theme.canvasColor, + onPressed: () => + widget.controller.formatSelection(_valueAttribute[index]), + child: Text( + _valueString[index], + style: style.copyWith( + color: _valueToText[_value] == _valueString[index] + ? theme.primaryIconTheme.color + : theme.iconTheme.color, + ), + ), + ), + ), + ); + }), + ); + } + + void _didChangeEditingValue() { + setState(() { + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + }); + } + + @override + void didUpdateWidget(covariant SelectHeaderStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } +} diff --git a/lib/src/widgets/toolbar/toggle_check_list_button.dart b/lib/src/widgets/toolbar/toggle_check_list_button.dart new file mode 100644 index 00000000..861da445 --- /dev/null +++ b/lib/src/widgets/toolbar/toggle_check_list_button.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/style.dart'; +import '../controller.dart'; +import '../toolbar.dart'; +import 'toggle_style_button.dart'; + +class ToggleCheckListButton extends StatefulWidget { + const ToggleCheckListButton({ + required this.icon, + required this.controller, + required this.attribute, + this.iconSize = kDefaultIconSize, + this.fillColor, + this.childBuilder = defaultToggleStyleButtonBuilder, + Key? key, + }) : super(key: key); + + final IconData icon; + final double iconSize; + + final Color? fillColor; + + final QuillController controller; + + final ToggleStyleButtonBuilder childBuilder; + + final Attribute attribute; + + @override + _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); +} + +class _ToggleCheckListButtonState extends State { + bool? _isToggled; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _isToggled = + _getIsToggled(widget.controller.getSelectionStyle().attributes); + }); + } + + @override + void initState() { + super.initState(); + _isToggled = _getIsToggled(_selectionStyle.attributes); + widget.controller.addListener(_didChangeEditingValue); + } + + bool _getIsToggled(Map attrs) { + if (widget.attribute.key == Attribute.list.key) { + final attribute = attrs[widget.attribute.key]; + if (attribute == null) { + return false; + } + return attribute.value == widget.attribute.value || + attribute.value == Attribute.checked.value; + } + return attrs.containsKey(widget.attribute.key); + } + + @override + void didUpdateWidget(covariant ToggleCheckListButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggled = _getIsToggled(_selectionStyle.attributes); + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isInCodeBlock = + _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); + final isEnabled = + !isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; + return widget.childBuilder( + context, + Attribute.unchecked, + widget.icon, + widget.fillColor, + _isToggled, + isEnabled ? _toggleAttribute : null, + widget.iconSize, + ); + } + + void _toggleAttribute() { + widget.controller.formatSelection(_isToggled! + ? Attribute.clone(Attribute.unchecked, null) + : Attribute.unchecked); + } +} diff --git a/lib/src/widgets/toolbar/toggle_style_button.dart b/lib/src/widgets/toolbar/toggle_style_button.dart new file mode 100644 index 00000000..624a31f9 --- /dev/null +++ b/lib/src/widgets/toolbar/toggle_style_button.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/style.dart'; +import '../controller.dart'; +import '../toolbar.dart'; +import 'quill_icon_button.dart'; + +typedef ToggleStyleButtonBuilder = Widget Function( + BuildContext context, + Attribute attribute, + IconData icon, + Color? fillColor, + bool? isToggled, + VoidCallback? onPressed, [ + double iconSize, +]); + +class ToggleStyleButton extends StatefulWidget { + const ToggleStyleButton({ + required this.attribute, + required this.icon, + required this.controller, + this.iconSize = kDefaultIconSize, + this.fillColor, + this.childBuilder = defaultToggleStyleButtonBuilder, + Key? key, + }) : super(key: key); + + final Attribute attribute; + + final IconData icon; + final double iconSize; + + final Color? fillColor; + + final QuillController controller; + + final ToggleStyleButtonBuilder childBuilder; + + @override + _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); +} + +class _ToggleStyleButtonState extends State { + bool? _isToggled; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + @override + void initState() { + super.initState(); + _isToggled = _getIsToggled(_selectionStyle.attributes); + widget.controller.addListener(_didChangeEditingValue); + } + + @override + Widget build(BuildContext context) { + final isInCodeBlock = + _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); + final isEnabled = + !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; + return widget.childBuilder( + context, + widget.attribute, + widget.icon, + widget.fillColor, + _isToggled, + isEnabled ? _toggleAttribute : null, + widget.iconSize, + ); + } + + @override + void didUpdateWidget(covariant ToggleStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggled = _getIsToggled(_selectionStyle.attributes); + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + void _didChangeEditingValue() { + setState(() => _isToggled = _getIsToggled(_selectionStyle.attributes)); + } + + bool _getIsToggled(Map attrs) { + if (widget.attribute.key == Attribute.list.key) { + final attribute = attrs[widget.attribute.key]; + if (attribute == null) { + return false; + } + return attribute.value == widget.attribute.value; + } + return attrs.containsKey(widget.attribute.key); + } + + void _toggleAttribute() { + widget.controller.formatSelection(_isToggled! + ? Attribute.clone(widget.attribute, null) + : widget.attribute); + } +} + +Widget defaultToggleStyleButtonBuilder( + BuildContext context, + Attribute attribute, + IconData icon, + Color? fillColor, + bool? isToggled, + VoidCallback? onPressed, [ + double iconSize = kDefaultIconSize, +]) { + final theme = Theme.of(context); + final isEnabled = onPressed != null; + final iconColor = isEnabled + ? isToggled == true + ? theme.primaryIconTheme.color + : theme.iconTheme.color + : theme.disabledColor; + final fill = isToggled == true + ? theme.toggleableActiveColor + : fillColor ?? theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: iconSize * kIconButtonFactor, + icon: Icon(icon, size: iconSize, color: iconColor), + fillColor: fill, + onPressed: onPressed, + ); +} From 2b9d6bd71bc44649fd3a8bd8e8de191a87ac4e6e Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Mon, 24 May 2021 13:42:20 -0700 Subject: [PATCH 31/45] Upgrade to 1.3.1 --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 323da506..bfc8284b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.3.1] +* New logo. + ## [1.3.0] * Support flutter 2.2.0. diff --git a/pubspec.yaml b/pubspec.yaml index 7cf52633..e50130ff 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.3.0 +version: 1.3.1 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill From c63831d01458dfe00302d06eb1182e2d6197fbc9 Mon Sep 17 00:00:00 2001 From: hyouuu Date: Tue, 25 May 2021 11:58:23 -0700 Subject: [PATCH 32/45] Fix incorrect double to int cast, and guard against optional parent (#239) --- lib/src/models/documents/nodes/node.dart | 3 +++ lib/src/widgets/text_line.dart | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/src/models/documents/nodes/node.dart b/lib/src/models/documents/nodes/node.dart index 6bb0fb97..08335727 100644 --- a/lib/src/models/documents/nodes/node.dart +++ b/lib/src/models/documents/nodes/node.dart @@ -53,6 +53,9 @@ abstract class Node extends LinkedListEntry { /// Offset in characters of this node in the document. int get documentOffset { + if (parent == null) { + return offset; + } final parentOffset = (parent is! Root) ? parent!.documentOffset : 0; return parentOffset + offset; } diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 04a9567d..b03c0f9b 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -625,11 +625,11 @@ class RenderEditableTextLine extends RenderEditableBox { final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; final leadingWidth = _leading == null ? 0 - : _leading!.getMinIntrinsicWidth(height - verticalPadding) as int; + : _leading!.getMinIntrinsicWidth(height - verticalPadding).floor(); final bodyWidth = _body == null ? 0 : _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) - as int; + .floor(); return horizontalPadding + leadingWidth + bodyWidth; } @@ -640,11 +640,11 @@ class RenderEditableTextLine extends RenderEditableBox { final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; final leadingWidth = _leading == null ? 0 - : _leading!.getMaxIntrinsicWidth(height - verticalPadding) as int; + : _leading!.getMaxIntrinsicWidth(height - verticalPadding).ceil(); final bodyWidth = _body == null ? 0 : _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) - as int; + .ceil(); return horizontalPadding + leadingWidth + bodyWidth; } From a10197dec3813944b243ab88c8149f19ab851236 Mon Sep 17 00:00:00 2001 From: Xun Gong Date: Tue, 25 May 2021 12:24:43 -0700 Subject: [PATCH 33/45] use ceil instead of floor to make sure won't cause overflow --- lib/src/widgets/text_line.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index b03c0f9b..90f7ceff 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -625,11 +625,11 @@ class RenderEditableTextLine extends RenderEditableBox { final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; final leadingWidth = _leading == null ? 0 - : _leading!.getMinIntrinsicWidth(height - verticalPadding).floor(); + : _leading!.getMinIntrinsicWidth(height - verticalPadding).ceil(); final bodyWidth = _body == null ? 0 : _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) - .floor(); + .ceil(); return horizontalPadding + leadingWidth + bodyWidth; } From aba8032b8d12ba69e0125c9ebe9044d89a8f2f9d Mon Sep 17 00:00:00 2001 From: Ben Chung <1330575+yzxben@users.noreply.github.com> Date: Tue, 25 May 2021 16:51:34 -0400 Subject: [PATCH 34/45] Fix example project Podfile (#241) --- example/ios/Podfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/example/ios/Podfile b/example/ios/Podfile index f7d6a5e6..1e8c3c90 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -28,6 +28,9 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + use_frameworks! + use_modular_headers! + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end From 16d6f243b8adb28c134fdcf79689608e98861128 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Sat, 29 May 2021 18:52:21 +0200 Subject: [PATCH 35/45] Show arrow indicator on toolbar (#245) --- lib/src/widgets/toolbar.dart | 15 +-- .../toolbar/arrow_indicated_button_list.dart | 125 ++++++++++++++++++ 2 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 lib/src/widgets/toolbar/arrow_indicated_button_list.dart diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index d4d00f5d..6b3a5e8c 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart'; import '../models/documents/attribute.dart'; import 'controller.dart'; +import 'toolbar/arrow_indicated_button_list.dart'; import 'toolbar/clear_format_button.dart'; import 'toolbar/color_button.dart'; import 'toolbar/history_button.dart'; @@ -316,21 +317,9 @@ class _QuillToolbarState extends State { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8), constraints: BoxConstraints.tightFor(height: widget.preferredSize.height), color: Theme.of(context).canvasColor, - child: CustomScrollView( - scrollDirection: Axis.horizontal, - slivers: [ - SliverFillRemaining( - hasScrollBody: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: widget.children, - ), - ), - ], - ), + child: ArrowIndicatedButtonList(buttons: widget.children), ); } } diff --git a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart new file mode 100644 index 00000000..b17157d4 --- /dev/null +++ b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Scrollable list with arrow indicators. +/// +/// The arrow indicators are automatically hidden if the list is not +/// scrollable in the direction of the respective arrow. +class ArrowIndicatedButtonList extends StatefulWidget { + const ArrowIndicatedButtonList({required this.buttons, Key? key}) + : super(key: key); + + final List buttons; + + @override + _ArrowIndicatedButtonListState createState() => + _ArrowIndicatedButtonListState(); +} + +class _ArrowIndicatedButtonListState extends State + with WidgetsBindingObserver { + final ScrollController _controller = ScrollController(); + bool _showLeftArrow = false; + bool _showRightArrow = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_handleScroll); + + // Listening to the WidgetsBinding instance is necessary so that we can + // hide the arrows when the window gets a new size and thus the toolbar + // becomes scrollable/unscrollable. + WidgetsBinding.instance!.addObserver(this); + + // Workaround to allow the scroll controller attach to our ListView so that + // we can detect if overflow arrows need to be shown on init. + Timer.run(_handleScroll); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _buildLeftArrow(), + _buildScrollableList(), + _buildRightColor(), + ], + ); + } + + @override + void didChangeMetrics() => _handleScroll(); + + @override + void dispose() { + _controller.dispose(); + WidgetsBinding.instance!.removeObserver(this); + super.dispose(); + } + + void _handleScroll() { + setState(() { + _showLeftArrow = + _controller.position.minScrollExtent != _controller.position.pixels; + _showRightArrow = + _controller.position.maxScrollExtent != _controller.position.pixels; + }); + } + + Widget _buildLeftArrow() { + return SizedBox( + width: 8, + child: Transform.translate( + // Move the icon a few pixels to center it + offset: const Offset(-5, 0), + child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null, + ), + ); + } + + Widget _buildScrollableList() { + return Expanded( + child: ScrollConfiguration( + // Remove the glowing effect, as we already have the arrow indicators + behavior: _NoGlowBehavior(), + // The CustomScrollView is necessary so that the children are not + // stretched to the height of the toolbar, https://bit.ly/3uC3bjI + child: CustomScrollView( + scrollDirection: Axis.horizontal, + controller: _controller, + physics: const ClampingScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widget.buttons, + ), + ) + ], + ), + ), + ); + } + + Widget _buildRightColor() { + return SizedBox( + width: 8, + child: Transform.translate( + // Move the icon a few pixels to center it + offset: const Offset(-5, 0), + child: _showRightArrow ? const Icon(Icons.arrow_right, size: 18) : null, + ), + ); + } +} + +/// ScrollBehavior without the Material glow effect. +class _NoGlowBehavior extends ScrollBehavior { + @override + Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) { + return child; + } +} From cdf50b579d064f37c4642b72ba203edd5b9007b4 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Mon, 31 May 2021 18:00:53 +0200 Subject: [PATCH 36/45] Add color parameter to Toolbar and ImageButton In addition, change these widgets to stateless widgets, since these widgets do not have a state and thus stateful is superfluous. --- lib/src/widgets/toolbar.dart | 18 +++++---- lib/src/widgets/toolbar/image_button.dart | 47 ++++++++++------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 6b3a5e8c..3b4b4348 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -40,10 +40,11 @@ const double kDefaultIconSize = 18; // The factor of how much larger the button is in relation to the icon. const double kIconButtonFactor = 1.77; -class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { +class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { const QuillToolbar({ required this.children, this.toolBarHeight = 36, + this.color, Key? key, }) : super(key: key); @@ -306,20 +307,21 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { final List children; final double toolBarHeight; - @override - _QuillToolbarState createState() => _QuillToolbarState(); + /// The color of the toolbar. + /// + /// Defaults to [ThemeData.canvasColor] of the current [Theme] if no color + /// is given. + final Color? color; @override Size get preferredSize => Size.fromHeight(toolBarHeight); -} -class _QuillToolbarState extends State { @override Widget build(BuildContext context) { return Container( - constraints: BoxConstraints.tightFor(height: widget.preferredSize.height), - color: Theme.of(context).canvasColor, - child: ArrowIndicatedButtonList(buttons: widget.children), + constraints: BoxConstraints.tightFor(height: preferredSize.height), + color: color ?? Theme.of(context).canvasColor, + child: ArrowIndicatedButtonList(buttons: children), ); } } diff --git a/lib/src/widgets/toolbar/image_button.dart b/lib/src/widgets/toolbar/image_button.dart index 33e191a9..740ef269 100644 --- a/lib/src/widgets/toolbar/image_button.dart +++ b/lib/src/widgets/toolbar/image_button.dart @@ -12,12 +12,13 @@ import '../controller.dart'; import '../toolbar.dart'; import 'quill_icon_button.dart'; -class ImageButton extends StatefulWidget { +class ImageButton extends StatelessWidget { const ImageButton({ required this.icon, required this.controller, required this.imageSource, this.iconSize = kDefaultIconSize, + this.fillColor, this.onImagePickCallback, this.imagePickImpl, Key? key, @@ -26,6 +27,8 @@ class ImageButton extends StatefulWidget { final IconData icon; final double iconSize; + final Color? fillColor; + final QuillController controller; final OnImagePickCallback? onImagePickCallback; @@ -34,49 +37,39 @@ class ImageButton extends StatefulWidget { final ImageSource imageSource; - @override - _ImageButtonState createState() => _ImageButtonState(); -} - -class _ImageButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); return QuillIconButton( - icon: Icon( - widget.icon, - size: widget.iconSize, - color: theme.iconTheme.color, - ), + icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), highlightElevation: 0, hoverElevation: 0, - size: widget.iconSize * 1.77, - fillColor: theme.canvasColor, - onPressed: _handleImageButtonTap, + size: iconSize * 1.77, + fillColor: fillColor ?? theme.canvasColor, + onPressed: () => _handleImageButtonTap(context), ); } - Future _handleImageButtonTap() async { - final index = widget.controller.selection.baseOffset; - final length = widget.controller.selection.extentOffset - index; + Future _handleImageButtonTap(BuildContext context) async { + final index = controller.selection.baseOffset; + final length = controller.selection.extentOffset - index; String? imageUrl; - if (widget.imagePickImpl != null) { - imageUrl = await widget.imagePickImpl!(widget.imageSource); + if (imagePickImpl != null) { + imageUrl = await imagePickImpl!(imageSource); } else { if (kIsWeb) { imageUrl = await _pickImageWeb(); } else if (Platform.isAndroid || Platform.isIOS) { - imageUrl = await _pickImage(widget.imageSource); + imageUrl = await _pickImage(imageSource); } else { - imageUrl = await _pickImageDesktop(); + imageUrl = await _pickImageDesktop(context); } } if (imageUrl != null) { - widget.controller - .replaceText(index, length, BlockEmbed.image(imageUrl), null); + controller.replaceText(index, length, BlockEmbed.image(imageUrl), null); } } @@ -90,7 +83,7 @@ class _ImageButtonState extends State { final fileName = result.files.first.name!; final file = File(fileName); - return widget.onImagePickCallback!(file); + return onImagePickCallback!(file); } Future _pickImage(ImageSource source) async { @@ -99,10 +92,10 @@ class _ImageButtonState extends State { return null; } - return widget.onImagePickCallback!(File(pickedFile.path)); + return onImagePickCallback!(File(pickedFile.path)); } - Future _pickImageDesktop() async { + Future _pickImageDesktop(BuildContext context) async { final filePath = await FilesystemPicker.open( context: context, rootDirectory: await getApplicationDocumentsDirectory(), @@ -112,6 +105,6 @@ class _ImageButtonState extends State { if (filePath == null || filePath.isEmpty) return null; final file = File(filePath); - return widget.onImagePickCallback!(file); + return onImagePickCallback!(file); } } From e2ab4df8cd438a77d15700b0b0aadf8c3b5fa01d Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Tue, 1 Jun 2021 18:23:00 +0200 Subject: [PATCH 37/45] Fix paste bug --- lib/src/widgets/raw_editor.dart | 6 ------ .../raw_editor_state_selection_delegate_mixin.dart | 8 ++++++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index b94472b6..7cfb9103 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -676,12 +676,6 @@ class RawEditorState extends EditorState @override bool get wantKeepAlive => widget.focusNode.hasFocus; - - @override - void userUpdateTextEditingValue( - TextEditingValue value, SelectionChangedCause cause) { - updateEditingValue(value); - } } class _Editor extends MultiChildRenderObjectWidget { diff --git a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart index cda991cc..7da1c66e 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -26,6 +26,14 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState } } + @override + void userUpdateTextEditingValue( + TextEditingValue value, + SelectionChangedCause cause, + ) { + setTextEditingValue(value); + } + @override bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; From 3ca9e966aced04b582ad6c48fbfb3ff8a64c87a3 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Tue, 1 Jun 2021 18:58:53 +0200 Subject: [PATCH 38/45] Remove extraneous toolbar dividers in certain configuration Closes #193. --- lib/src/widgets/toolbar.dart | 368 ++++++++++++++++------------------- 1 file changed, 167 insertions(+), 201 deletions(-) diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 3b4b4348..76be1824 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -71,237 +71,203 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { OnImagePickCallback? onImagePickCallback, Key? key, }) { + final isButtonGroupShown = [ + showHistory || + showBoldButton || + showItalicButton || + showUnderLineButton || + showStrikeThrough || + showColorButton || + showBackgroundColorButton || + showClearFormat || + onImagePickCallback != null, + showHeaderStyle, + showListNumbers || showListBullets || showListCheck || showCodeBlock, + showQuote || showIndent, + showLink || showHorizontalRule + ]; + return QuillToolbar( - key: key, - toolBarHeight: toolbarIconSize * 2, - children: [ - Visibility( - visible: showHistory, - child: HistoryButton( - icon: Icons.undo_outlined, - iconSize: toolbarIconSize, - controller: controller, - undo: true, - ), + key: key, + toolBarHeight: toolbarIconSize * 2, + children: [ + if (showHistory) + HistoryButton( + icon: Icons.undo_outlined, + iconSize: toolbarIconSize, + controller: controller, + undo: true, ), - Visibility( - visible: showHistory, - child: HistoryButton( - icon: Icons.redo_outlined, - iconSize: toolbarIconSize, - controller: controller, - undo: false, - ), + if (showHistory) + HistoryButton( + icon: Icons.redo_outlined, + iconSize: toolbarIconSize, + controller: controller, + undo: false, ), - const SizedBox(width: 0.6), - Visibility( - visible: showBoldButton, - child: ToggleStyleButton( - attribute: Attribute.bold, - icon: Icons.format_bold, - iconSize: toolbarIconSize, - controller: controller, - ), + if (showBoldButton) + ToggleStyleButton( + attribute: Attribute.bold, + icon: Icons.format_bold, + iconSize: toolbarIconSize, + controller: controller, ), - const SizedBox(width: 0.6), - Visibility( - visible: showItalicButton, - child: ToggleStyleButton( - attribute: Attribute.italic, - icon: Icons.format_italic, - iconSize: toolbarIconSize, - controller: controller, - ), + if (showItalicButton) + ToggleStyleButton( + attribute: Attribute.italic, + icon: Icons.format_italic, + iconSize: toolbarIconSize, + controller: controller, ), - const SizedBox(width: 0.6), - Visibility( - visible: showUnderLineButton, - child: ToggleStyleButton( - attribute: Attribute.underline, - icon: Icons.format_underline, - iconSize: toolbarIconSize, - controller: controller, - ), + if (showUnderLineButton) + ToggleStyleButton( + attribute: Attribute.underline, + icon: Icons.format_underline, + iconSize: toolbarIconSize, + controller: controller, ), - const SizedBox(width: 0.6), - Visibility( - visible: showStrikeThrough, - child: ToggleStyleButton( - attribute: Attribute.strikeThrough, - icon: Icons.format_strikethrough, - iconSize: toolbarIconSize, - controller: controller, - ), + if (showStrikeThrough) + ToggleStyleButton( + attribute: Attribute.strikeThrough, + icon: Icons.format_strikethrough, + iconSize: toolbarIconSize, + controller: controller, ), - const SizedBox(width: 0.6), - Visibility( - visible: showColorButton, - child: ColorButton( - icon: Icons.color_lens, - iconSize: toolbarIconSize, - controller: controller, - background: false, - ), + if (showColorButton) + ColorButton( + icon: Icons.color_lens, + iconSize: toolbarIconSize, + controller: controller, + background: false, ), - const SizedBox(width: 0.6), - Visibility( - visible: showBackgroundColorButton, - child: ColorButton( - icon: Icons.format_color_fill, - iconSize: toolbarIconSize, - controller: controller, - background: true, - ), + if (showBackgroundColorButton) + ColorButton( + icon: Icons.format_color_fill, + iconSize: toolbarIconSize, + controller: controller, + background: true, ), - const SizedBox(width: 0.6), - Visibility( - visible: showClearFormat, - child: ClearFormatButton( - icon: Icons.format_clear, - iconSize: toolbarIconSize, - controller: controller, - ), + if (showClearFormat) + ClearFormatButton( + icon: Icons.format_clear, + iconSize: toolbarIconSize, + controller: controller, ), - const SizedBox(width: 0.6), - Visibility( - visible: onImagePickCallback != null, - child: ImageButton( - icon: Icons.image, - iconSize: toolbarIconSize, - controller: controller, - imageSource: ImageSource.gallery, - onImagePickCallback: onImagePickCallback, - ), + if (onImagePickCallback != null) + ImageButton( + icon: Icons.image, + iconSize: toolbarIconSize, + controller: controller, + imageSource: ImageSource.gallery, + onImagePickCallback: onImagePickCallback, ), - const SizedBox(width: 0.6), - Visibility( - visible: onImagePickCallback != null, - child: ImageButton( - icon: Icons.photo_camera, - iconSize: toolbarIconSize, - controller: controller, - imageSource: ImageSource.camera, - onImagePickCallback: onImagePickCallback, - ), + if (onImagePickCallback != null) + ImageButton( + icon: Icons.photo_camera, + iconSize: toolbarIconSize, + controller: controller, + imageSource: ImageSource.camera, + onImagePickCallback: onImagePickCallback, ), - Visibility( - visible: showHeaderStyle, - child: VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + if (isButtonGroupShown[0] && + (isButtonGroupShown[1] || + isButtonGroupShown[2] || + isButtonGroupShown[3] || + isButtonGroupShown[4])) + VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, ), - Visibility( - visible: showHeaderStyle, - child: SelectHeaderStyleButton( - controller: controller, - iconSize: toolbarIconSize, - ), + if (showHeaderStyle) + SelectHeaderStyleButton( + controller: controller, + iconSize: toolbarIconSize, ), + if (isButtonGroupShown[1] && + (isButtonGroupShown[2] || + isButtonGroupShown[3] || + isButtonGroupShown[4])) VerticalDivider( indent: 12, endIndent: 12, color: Colors.grey.shade400, ), - Visibility( - visible: showListNumbers, - child: ToggleStyleButton( - attribute: Attribute.ol, - controller: controller, - icon: Icons.format_list_numbered, - iconSize: toolbarIconSize, - ), + if (showListNumbers) + ToggleStyleButton( + attribute: Attribute.ol, + controller: controller, + icon: Icons.format_list_numbered, + iconSize: toolbarIconSize, ), - Visibility( - visible: showListBullets, - child: ToggleStyleButton( - attribute: Attribute.ul, - controller: controller, - icon: Icons.format_list_bulleted, - iconSize: toolbarIconSize, - ), + if (showListBullets) + ToggleStyleButton( + attribute: Attribute.ul, + controller: controller, + icon: Icons.format_list_bulleted, + iconSize: toolbarIconSize, ), - Visibility( - visible: showListCheck, - child: ToggleCheckListButton( - attribute: Attribute.unchecked, - controller: controller, - icon: Icons.check_box, - iconSize: toolbarIconSize, - ), + if (showListCheck) + ToggleCheckListButton( + attribute: Attribute.unchecked, + controller: controller, + icon: Icons.check_box, + iconSize: toolbarIconSize, ), - Visibility( - visible: showCodeBlock, - child: ToggleStyleButton( - attribute: Attribute.codeBlock, - controller: controller, - icon: Icons.code, - iconSize: toolbarIconSize, - ), + if (showCodeBlock) + ToggleStyleButton( + attribute: Attribute.codeBlock, + controller: controller, + icon: Icons.code, + iconSize: toolbarIconSize, ), - Visibility( - visible: !showListNumbers && - !showListBullets && - !showListCheck && - !showCodeBlock, - child: VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + if (isButtonGroupShown[2] && + (isButtonGroupShown[3] || isButtonGroupShown[4])) + VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, ), - Visibility( - visible: showQuote, - child: ToggleStyleButton( - attribute: Attribute.blockQuote, - controller: controller, - icon: Icons.format_quote, - iconSize: toolbarIconSize, - ), + if (showQuote) + ToggleStyleButton( + attribute: Attribute.blockQuote, + controller: controller, + icon: Icons.format_quote, + iconSize: toolbarIconSize, ), - Visibility( - visible: showIndent, - child: IndentButton( - icon: Icons.format_indent_increase, - iconSize: toolbarIconSize, - controller: controller, - isIncrease: true, - ), + if (showIndent) + IndentButton( + icon: Icons.format_indent_increase, + iconSize: toolbarIconSize, + controller: controller, + isIncrease: true, ), - Visibility( - visible: showIndent, - child: IndentButton( - icon: Icons.format_indent_decrease, - iconSize: toolbarIconSize, - controller: controller, - isIncrease: false, - ), + if (showIndent) + IndentButton( + icon: Icons.format_indent_decrease, + iconSize: toolbarIconSize, + controller: controller, + isIncrease: false, ), - Visibility( - visible: showQuote, - child: VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + if (isButtonGroupShown[3] && isButtonGroupShown[4]) + VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, ), - Visibility( - visible: showLink, - child: LinkStyleButton( - controller: controller, - iconSize: toolbarIconSize, - ), + if (showLink) + LinkStyleButton( + controller: controller, + iconSize: toolbarIconSize, ), - Visibility( - visible: showHorizontalRule, - child: InsertEmbedButton( - controller: controller, - icon: Icons.horizontal_rule, - iconSize: toolbarIconSize, - ), + if (showHorizontalRule) + InsertEmbedButton( + controller: controller, + icon: Icons.horizontal_rule, + iconSize: toolbarIconSize, ), - ]); + ], + ); } final List children; From 6707181184ffb874117ea20f6fe13c8cd33a1314 Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Tue, 1 Jun 2021 11:11:48 -0700 Subject: [PATCH 39/45] Upgrade version to 1.3.2 --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc8284b..40b95eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.3.2] +* Fix copy/paste bug. + ## [1.3.1] * New logo. diff --git a/pubspec.yaml b/pubspec.yaml index e50130ff..1ce0c9bc 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.3.1 +version: 1.3.2 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill From 4bcb73fd25984881e44cabaec4f05500c5137d0c Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Tue, 1 Jun 2021 12:19:09 -0700 Subject: [PATCH 40/45] Format code --- lib/src/widgets/cursor.dart | 2 +- lib/src/widgets/text_line.dart | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 9fef16e5..763d396d 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -101,7 +101,7 @@ class CursorCont extends ChangeNotifier { required this.show, required CursorStyle style, required TickerProvider tickerProvider, - }) : _style = style, + }) : _style = style, blink = ValueNotifier(false), color = ValueNotifier(style.color) { _blinkOpacityController = diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 90f7ceff..d95923d3 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -628,7 +628,8 @@ class RenderEditableTextLine extends RenderEditableBox { : _leading!.getMinIntrinsicWidth(height - verticalPadding).ceil(); final bodyWidth = _body == null ? 0 - : _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) + : _body! + .getMinIntrinsicWidth(math.max(0, height - verticalPadding)) .ceil(); return horizontalPadding + leadingWidth + bodyWidth; } @@ -643,7 +644,8 @@ class RenderEditableTextLine extends RenderEditableBox { : _leading!.getMaxIntrinsicWidth(height - verticalPadding).ceil(); final bodyWidth = _body == null ? 0 - : _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) + : _body! + .getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) .ceil(); return horizontalPadding + leadingWidth + bodyWidth; } From ff93000041d8c4ca64a3b555cd8074441d5f1905 Mon Sep 17 00:00:00 2001 From: Till Friebe Date: Tue, 1 Jun 2021 23:51:25 +0200 Subject: [PATCH 41/45] Bump file_picker to 3.0.2+2 With version 3.0.2 `name` of the file_picker library becomes non-nullable, so a warning was issued for users who had already used version 3.0.2, as we still assumed that `name` is nullable. Increasing the version and removing the exclamation mark removes the warning. --- lib/src/widgets/toolbar/image_button.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/toolbar/image_button.dart b/lib/src/widgets/toolbar/image_button.dart index 740ef269..74e9ae6d 100644 --- a/lib/src/widgets/toolbar/image_button.dart +++ b/lib/src/widgets/toolbar/image_button.dart @@ -80,7 +80,7 @@ class ImageButton extends StatelessWidget { } // Take first, because we don't allow picking multiple files. - final fileName = result.files.first.name!; + final fileName = result.files.first.name; final file = File(fileName); return onImagePickCallback!(file); diff --git a/pubspec.yaml b/pubspec.yaml index 1ce0c9bc..f7484234 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: flutter: sdk: flutter collection: ^1.15.0 - file_picker: ^3.0.0 + file_picker: ^3.0.2+2 filesystem_picker: ^2.0.0-nullsafety.0 flutter_colorpicker: ^0.4.0 flutter_keyboard_visibility: ^5.0.0 From 06a7f4aa2e0150b4fac4123505f7af6ce0d498b8 Mon Sep 17 00:00:00 2001 From: hyouuu Date: Wed, 2 Jun 2021 00:17:27 -0700 Subject: [PATCH 42/45] Fix a bug that Embed could be together with Text (#249) --- lib/src/widgets/text_line.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index d95923d3..c258acc0 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -37,8 +37,14 @@ class TextLine extends StatelessWidget { Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); - if (line.hasEmbed) { - final embed = line.children.single as Embed; + // In rare circumstances, the line could contain an Embed & a Text of + // newline, which is unexpected and probably we should find out the + // root cause + final childCount = line.childCount; + if (line.hasEmbed || + (childCount > 1 && line.children.first is Embed)) + { + final embed = line.children.first as Embed; return EmbedProxy(embedBuilder(context, embed)); } From e69e97481bbe21f5e204668d24921b271720e220 Mon Sep 17 00:00:00 2001 From: lucasbstn <64323294+lucasbstn@users.noreply.github.com> Date: Thu, 3 Jun 2021 20:01:00 +0300 Subject: [PATCH 43/45] Fix #242 (#254) --- lib/src/widgets/toolbar/color_button.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/src/widgets/toolbar/color_button.dart b/lib/src/widgets/toolbar/color_button.dart index fa5bb520..fa757e8a 100644 --- a/lib/src/widgets/toolbar/color_button.dart +++ b/lib/src/widgets/toolbar/color_button.dart @@ -125,7 +125,7 @@ class _ColorButtonState extends State { ); } - void _changeColor(Color color) { + void _changeColor(BuildContext context, Color color) { var hex = color.value.toRadixString(16); if (hex.startsWith('ff')) { hex = hex.substring(2); @@ -139,15 +139,16 @@ class _ColorButtonState extends State { void _showColorPicker() { showDialog( context: context, - builder: (_) => AlertDialog( - title: const Text('Select Color'), - backgroundColor: Theme.of(context).canvasColor, - content: SingleChildScrollView( - child: MaterialPicker( - pickerColor: const Color(0x00000000), - onColorChanged: _changeColor, - ), - )), + builder: (context) => AlertDialog( + title: const Text('Select Color'), + backgroundColor: Theme.of(context).canvasColor, + content: SingleChildScrollView( + child: MaterialPicker( + pickerColor: const Color(0x00000000), + onColorChanged: (color) => _changeColor(context, color), + ), + ), + ), ); } } From 76ef63e87c1fed0b867daec9402649112ee71d0e Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Thu, 3 Jun 2021 10:45:13 -0700 Subject: [PATCH 44/45] Upgrade to 1.3.3 --- CHANGELOG.md | 3 +++ lib/src/widgets/text_line.dart | 4 +--- pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b95eab..601b3bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.3.3] +* Upgrade file_picker version. + ## [1.3.2] * Fix copy/paste bug. diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index c258acc0..b44f16a9 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -41,9 +41,7 @@ class TextLine extends StatelessWidget { // newline, which is unexpected and probably we should find out the // root cause final childCount = line.childCount; - if (line.hasEmbed || - (childCount > 1 && line.children.first is Embed)) - { + if (line.hasEmbed || (childCount > 1 && line.children.first is Embed)) { final embed = line.children.first as Embed; return EmbedProxy(embedBuilder(context, embed)); } diff --git a/pubspec.yaml b/pubspec.yaml index f7484234..98818c9c 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.3.2 +version: 1.3.3 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill From 71a0cec282433e3f2e0828d9281a0f6321cd4514 Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Thu, 3 Jun 2021 20:38:21 -0700 Subject: [PATCH 45/45] Format code --- lib/src/widgets/cursor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 763d396d..9fef16e5 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -101,7 +101,7 @@ class CursorCont extends ChangeNotifier { required this.show, required CursorStyle style, required TickerProvider tickerProvider, - }) : _style = style, + }) : _style = style, blink = ValueNotifier(false), color = ValueNotifier(style.color) { _blinkOpacityController =