diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md index ff759a4b..077b59c0 100644 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -13,4 +13,4 @@ My issue is about [Desktop] I have tried running `example` directory successfully before creating an issue here. -Please note that we are using stable channel. If you are using beta or master channel, those are not supported. +Please note that we are using stable channel on branch master. If you are using beta or master channel, use branch dev. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8937de42..62e3a30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.2.2] +* Checkbox supports tapping. + +## [1.2.1] +* Indented position not holding while editing. + ## [1.2.0] * Fix image button cancel causes crash. diff --git a/lib/models/documents/attribute.dart b/lib/models/documents/attribute.dart index 18822e01..1b9043b9 100644 --- a/lib/models/documents/attribute.dart +++ b/lib/models/documents/attribute.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:quiver/core.dart'; enum AttributeScope { @@ -14,7 +16,7 @@ class Attribute { final AttributeScope scope; final T value; - static final Map _registry = { + static final Map _registry = LinkedHashMap.of({ Attribute.bold.key: Attribute.bold, Attribute.italic.key: Attribute.italic, Attribute.underline.key: Attribute.underline, @@ -26,16 +28,16 @@ class Attribute { Attribute.background.key: Attribute.background, Attribute.placeholder.key: Attribute.placeholder, Attribute.header.key: Attribute.header, - Attribute.indent.key: Attribute.indent, 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(); @@ -88,22 +90,22 @@ class Attribute { Attribute.placeholder.key, }; - static final Set blockKeys = { + static final Set blockKeys = LinkedHashSet.of({ Attribute.header.key, - Attribute.indent.key, Attribute.align.key, Attribute.list.key, Attribute.codeBlock.key, Attribute.blockQuote.key, - }; + Attribute.indent.key, + }); - static final Set blockKeysExceptHeader = { + static final Set blockKeysExceptHeader = LinkedHashSet.of({ Attribute.list.key, - Attribute.indent.key, Attribute.align.key, Attribute.codeBlock.key, Attribute.blockQuote.key, - }; + Attribute.indent.key, + }); static Attribute get h1 => HeaderAttribute(level: 1); @@ -151,7 +153,10 @@ class Attribute { if (level == 2) { return indentL2; } - return indentL3; + if (level == 3) { + return indentL3; + } + return IndentAttribute(level: level); } bool get isInline => scope == AttributeScope.INLINE; @@ -169,6 +174,18 @@ class Attribute { 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); } diff --git a/lib/models/documents/document.dart b/lib/models/documents/document.dart index 671bbcd6..9d966f32 100644 --- a/lib/models/documents/document.dart +++ b/lib/models/documents/document.dart @@ -234,7 +234,12 @@ class Document { 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) { diff --git a/lib/models/documents/nodes/line.dart b/lib/models/documents/nodes/line.dart index ec933b52..fabfad4d 100644 --- a/lib/models/documents/nodes/line.dart +++ b/lib/models/documents/nodes/line.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:collection/collection.dart'; + import '../../quill_delta.dart'; import '../attribute.dart'; import '../style.dart'; @@ -203,21 +205,27 @@ class Line extends Container { } // No block-level changes if (parent is Block) { - final parentStyle = (parent as Block).style.getBlockExceptHeader(); + final parentStyle = (parent as Block).style.getBlocksExceptHeader(); if (blockStyle.value == null) { _unwrap(); - } else if (blockStyle != parentStyle) { + } else if (!const MapEquality() + .equals(newStyle.getBlocksExceptHeader(), parentStyle)) { _unwrap(); - final block = Block()..applyAttribute(blockStyle); - _wrap(block); - block.adjust(); + _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 - final block = Block()..applyAttribute(blockStyle); - _wrap(block); - block.adjust(); + _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]. diff --git a/lib/models/documents/style.dart b/lib/models/documents/style.dart index c805280d..fade1bb5 100644 --- a/lib/models/documents/style.dart +++ b/lib/models/documents/style.dart @@ -30,7 +30,8 @@ class Style { Iterable get keys => _attributes.keys; - Iterable get values => _attributes.values; + Iterable get values => _attributes.values.sorted( + (a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b)); Map get attributes => _attributes; @@ -48,6 +49,11 @@ class Style { 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; @@ -56,6 +62,16 @@ class Style { 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) { diff --git a/lib/models/quill_delta.dart b/lib/models/quill_delta.dart index 895b27fe..a0e608be 100644 --- a/lib/models/quill_delta.dart +++ b/lib/models/quill_delta.dart @@ -265,7 +265,7 @@ class Delta { List toList() => List.from(_operations); /// Returns JSON-serializable version of this delta. - List toJson() => toList(); + List toJson() => toList().map((operation) => operation.toJson()).toList(); /// Returns `true` if this delta is empty. bool get isEmpty => _operations.isEmpty; diff --git a/lib/models/rules/insert.dart b/lib/models/rules/insert.dart index b83c8838..5801a10e 100644 --- a/lib/models/rules/insert.dart +++ b/lib/models/rules/insert.dart @@ -55,6 +55,14 @@ class PreserveLineStyleOnSplitRule extends InsertRule { } } +/// 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(); @@ -62,28 +70,32 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { 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 attribute = lineStyle.getBlockExceptHeader(); - if (attribute == null) { + final blockStyle = lineStyle.getBlocksExceptHeader(); + // Are we currently in a block? If not then ignore. + if (blockStyle.isEmpty) { return null; } - final blockStyle = {attribute.key: attribute.value}; - 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++) { @@ -92,12 +104,15 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { 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!) @@ -109,6 +124,12 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { } } +/// 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(); @@ -132,25 +153,39 @@ class AutoExitBlockRule extends InsertRule { 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)); diff --git a/lib/widgets/controller.dart b/lib/widgets/controller.dart index ad4286ec..4820b3f9 100644 --- a/lib/widgets/controller.dart +++ b/lib/widgets/controller.dart @@ -11,7 +11,11 @@ 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 this.selection, + this.iconSize = 18, + this.toolbarHeightFactor = 2}); factory QuillController.basic() { return QuillController( @@ -28,6 +32,13 @@ class QuillController extends ChangeNotifier { 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. @@ -179,9 +190,31 @@ class QuillController extends ChangeNotifier { 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() { - document.close(); + if (!_isDisposed) { + document.close(); + } + + _isDisposed = true; super.dispose(); } diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index 42fd769c..662a018c 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -176,18 +176,25 @@ class QuillEditor extends StatefulWidget { final ScrollPhysics? scrollPhysics; final ValueChanged? onLaunchUrl; // Returns whether gesture is handled - final bool Function(TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; + 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; + 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; + 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; + 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 bool Function( + LongPressEndDetails details, TextPosition Function(Offset offset))? + onSingleLongTapEnd; final EmbedBuilder embedBuilder; @@ -339,7 +346,8 @@ class _QuillEditorSelectionGestureDetectorBuilder if (_state.widget.onSingleLongTapMoveUpdate != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { - if (_state.widget.onSingleLongTapMoveUpdate!(details, renderEditor.getPositionForOffset)) { + if (_state.widget.onSingleLongTapMoveUpdate!( + details, renderEditor.getPositionForOffset)) { return; } } @@ -385,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; @@ -426,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 { @@ -468,7 +446,8 @@ class _QuillEditorSelectionGestureDetectorBuilder if (_state.widget.onTapDown != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { - if (_state.widget.onTapDown!(details, renderEditor.getPositionForOffset)) { + if (_state.widget.onTapDown!( + details, renderEditor.getPositionForOffset)) { return; } } @@ -481,7 +460,8 @@ class _QuillEditorSelectionGestureDetectorBuilder if (_state.widget.onTapUp != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { - if (_state.widget.onTapUp!(details, renderEditor.getPositionForOffset)) { + if (_state.widget.onTapUp!( + details, renderEditor.getPositionForOffset)) { return; } } @@ -523,7 +503,8 @@ class _QuillEditorSelectionGestureDetectorBuilder if (_state.widget.onSingleLongTapStart != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { - if (_state.widget.onSingleLongTapStart!(details, renderEditor.getPositionForOffset)) { + if (_state.widget.onSingleLongTapStart!( + details, renderEditor.getPositionForOffset)) { return; } } @@ -557,7 +538,8 @@ class _QuillEditorSelectionGestureDetectorBuilder if (_state.widget.onSingleLongTapEnd != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { - if (_state.widget.onSingleLongTapEnd!(details, renderEditor.getPositionForOffset)) { + if (_state.widget.onSingleLongTapEnd!( + details, renderEditor.getPositionForOffset)) { return; } } @@ -1061,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 7114e670..4df93a4d 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.'); @@ -913,14 +927,16 @@ class RawEditorState extends EditorState return; } _showCaretOnScreen(); - _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, widget.controller.selection); + _cursorCont.startOrStopCursorTimerIfNeeded( + _hasFocus, widget.controller.selection); if (hasConnection) { _cursorCont ..stopCursorTimer(resetCharTicks: false) ..startCursorTimer(); } - SchedulerBinding.instance!.addPostFrameCallback((_) => _updateOrDisposeSelectionOverlayIfNeeded()); + SchedulerBinding.instance!.addPostFrameCallback( + (_) => _updateOrDisposeSelectionOverlayIfNeeded()); if (mounted) { setState(() { // Use widget.controller.value in build() @@ -992,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, + ); + } } }); } diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart new file mode 100644 index 00000000..ee1a7732 --- /dev/null +++ b/lib/widgets/simple_viewer.dart @@ -0,0 +1,344 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:tuple/tuple.dart'; + +import '../models/documents/attribute.dart'; +import '../models/documents/document.dart'; +import '../models/documents/nodes/block.dart'; +import '../models/documents/nodes/leaf.dart' as leaf; +import '../models/documents/nodes/line.dart'; +import 'controller.dart'; +import 'cursor.dart'; +import 'default_styles.dart'; +import 'delegate.dart'; +import 'editor.dart'; +import 'text_block.dart'; +import 'text_line.dart'; + +class QuillSimpleViewer extends StatefulWidget { + const QuillSimpleViewer({ + required this.controller, + this.customStyles, + this.truncate = false, + this.truncateScale, + this.truncateAlignment, + this.truncateHeight, + this.truncateWidth, + this.scrollBottomInset = 0, + this.padding = EdgeInsets.zero, + this.embedBuilder, + Key? key, + }) : assert(truncate || + ((truncateScale == null) && + (truncateAlignment == null) && + (truncateHeight == null) && + (truncateWidth == null))), + super(key: key); + + final QuillController controller; + final DefaultStyles? customStyles; + final bool truncate; + final double? truncateScale; + final Alignment? truncateAlignment; + final double? truncateHeight; + final double? truncateWidth; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + final EmbedBuilder? embedBuilder; + + @override + _QuillSimpleViewerState createState() => _QuillSimpleViewerState(); +} + +class _QuillSimpleViewerState extends State + with SingleTickerProviderStateMixin { + late DefaultStyles _styles; + final LayerLink _toolbarLayerLink = LayerLink(); + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + late CursorCont _cursorCont; + + @override + void initState() { + super.initState(); + + _cursorCont = CursorCont( + show: ValueNotifier(false), + style: const CursorStyle( + color: Colors.black, + backgroundColor: Colors.grey, + width: 2, + radius: Radius.zero, + offset: Offset.zero, + ), + tickerProvider: this, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parentStyles = QuillStyles.getStyles(context, true); + final defaultStyles = DefaultStyles.getInstance(context); + _styles = (parentStyles != null) + ? defaultStyles.merge(parentStyles) + : defaultStyles; + + if (widget.customStyles != null) { + _styles = _styles.merge(widget.customStyles!); + } + } + + EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder; + + Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) { + assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); + switch (node.value.type) { + case 'image': + final imageUrl = _standardizeImageUrl(node.value.data); + return imageUrl.startsWith('http') + ? Image.network(imageUrl) + : isBase64(imageUrl) + ? Image.memory(base64.decode(imageUrl)) + : Image.file(io.File(imageUrl)); + default: + throw UnimplementedError( + 'Embeddable type "${node.value.type}" is not supported by default embed ' + 'builder of QuillEditor. You must pass your own builder function to ' + 'embedBuilder property of QuillEditor or QuillField widgets.'); + } + } + + String _standardizeImageUrl(String url) { + if (url.contains('base64')) { + return url.split(',')[1]; + } + return url; + } + + @override + Widget build(BuildContext context) { + final _doc = widget.controller.document; + // if (_doc.isEmpty() && + // !widget.focusNode.hasFocus && + // widget.placeholder != null) { + // _doc = Document.fromJson(jsonDecode( + // '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); + // } + + Widget child = CompositedTransformTarget( + link: _toolbarLayerLink, + child: Semantics( + child: _SimpleViewer( + document: _doc, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _nullSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + children: _buildChildren(_doc, context), + ), + ), + ); + + if (widget.truncate) { + if (widget.truncateScale != null) { + child = Container( + height: widget.truncateHeight, + child: Align( + heightFactor: widget.truncateScale, + widthFactor: widget.truncateScale, + alignment: widget.truncateAlignment ?? Alignment.topLeft, + child: Container( + width: widget.truncateWidth! / widget.truncateScale!, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Transform.scale( + scale: widget.truncateScale!, + alignment: + widget.truncateAlignment ?? Alignment.topLeft, + child: child))))); + } else { + child = Container( + height: widget.truncateHeight, + width: widget.truncateWidth, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), child: child)); + } + } + + return QuillStyles(data: _styles, child: child); + } + + List _buildChildren(Document doc, BuildContext context) { + final result = []; + final indentLevelCounts = {}; + for (final node in doc.root.children) { + if (node is Line) { + final editableTextLine = _getEditableTextLineFromNode(node, context); + result.add(editableTextLine); + } else if (node is Block) { + final attrs = node.style.attributes; + final editableTextBlock = EditableTextBlock( + node, + _textDirection, + widget.scrollBottomInset, + _getVerticalSpacingForBlock(node, _styles), + widget.controller.selection, + Colors.black, + // selectionColor, + _styles, + false, + // enableInteractiveSelection, + false, + // hasFocus, + attrs.containsKey(Attribute.codeBlock.key) + ? const EdgeInsets.all(16) + : null, + embedBuilder, + _cursorCont, + indentLevelCounts, + _handleCheckboxTap); + result.add(editableTextBlock); + } else { + throw StateError('Unreachable.'); + } + } + return result; + } + + /// Updates the checkbox positioned at [offset] in document + /// by changing its attribute according to [value]. + void _handleCheckboxTap(int offset, bool value) { + // readonly - do nothing + } + + TextDirection get _textDirection { + final result = Directionality.of(context); + return result; + } + + EditableTextLine _getEditableTextLineFromNode( + Line node, BuildContext context) { + final textLine = TextLine( + line: node, + textDirection: _textDirection, + embedBuilder: embedBuilder, + styles: _styles, + ); + final editableTextLine = EditableTextLine( + node, + null, + textLine, + 0, + _getVerticalSpacingForLine(node, _styles), + _textDirection, + widget.controller.selection, + Colors.black, + //widget.selectionColor, + false, + //enableInteractiveSelection, + false, + //_hasFocus, + MediaQuery.of(context).devicePixelRatio, + _cursorCont); + return editableTextLine; + } + + Tuple2 _getVerticalSpacingForLine( + Line line, DefaultStyles? defaultStyles) { + final attrs = line.style.attributes; + if (attrs.containsKey(Attribute.header.key)) { + final int? level = attrs[Attribute.header.key]!.value; + switch (level) { + case 1: + return defaultStyles!.h1!.verticalSpacing; + case 2: + return defaultStyles!.h2!.verticalSpacing; + case 3: + return defaultStyles!.h3!.verticalSpacing; + default: + throw 'Invalid level $level'; + } + } + + return defaultStyles!.paragraph!.verticalSpacing; + } + + Tuple2 _getVerticalSpacingForBlock( + Block node, DefaultStyles? defaultStyles) { + final attrs = node.style.attributes; + if (attrs.containsKey(Attribute.blockQuote.key)) { + return defaultStyles!.quote!.verticalSpacing; + } else if (attrs.containsKey(Attribute.codeBlock.key)) { + return defaultStyles!.code!.verticalSpacing; + } else if (attrs.containsKey(Attribute.indent.key)) { + return defaultStyles!.indent!.verticalSpacing; + } + return defaultStyles!.lists!.verticalSpacing; + } + + void _nullSelectionChanged( + TextSelection selection, SelectionChangedCause cause) {} +} + +class _SimpleViewer extends MultiChildRenderObjectWidget { + _SimpleViewer({ + required List children, + required this.document, + required this.textDirection, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.onSelectionChanged, + required this.scrollBottomInset, + this.padding = EdgeInsets.zero, + Key? key, + }) : super(key: key, children: children); + + final Document document; + final TextDirection textDirection; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final TextSelectionChangedHandler onSelectionChanged; + final double scrollBottomInset; + final EdgeInsetsGeometry padding; + + @override + RenderEditor createRenderObject(BuildContext context) { + return RenderEditor( + null, + textDirection, + scrollBottomInset, + padding, + document, + const TextSelection(baseOffset: 0, extentOffset: 0), + false, + // hasFocus, + onSelectionChanged, + startHandleLayerLink, + endHandleLayerLink, + const EdgeInsets.fromLTRB(4, 4, 4, 5), + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderEditor renderObject) { + renderObject + ..document = document + ..setContainer(document.root) + ..textDirection = textDirection + ..setStartHandleLayerLink(startHandleLayerLink) + ..setEndHandleLayerLink(endHandleLayerLink) + ..onSelectionChanged = onSelectionChanged + ..setScrollBottomInset(scrollBottomInset) + ..setPadding(padding); + } +} diff --git a/lib/widgets/text_block.dart b/lib/widgets/text_block.dart index 309b9cf8..f533a160 100644 --- a/lib/widgets/text_block.dart +++ b/lib/widgets/text_block.dart @@ -61,6 +61,7 @@ class EditableTextBlock extends StatelessWidget { this.embedBuilder, this.cursorCont, this.indentLevelCounts, + this.onCheckboxTap, ); final Block block; @@ -76,6 +77,7 @@ class EditableTextBlock extends StatelessWidget { final EmbedBuilder embedBuilder; final CursorCont cursorCont; final Map indentLevelCounts; + final Function(int, bool) onCheckboxTap; @override Widget build(BuildContext context) { @@ -161,12 +163,23 @@ class EditableTextBlock extends StatelessWidget { if (attrs[Attribute.list.key] == Attribute.checked) { return _Checkbox( - style: defaultStyles!.leading!.style, width: 32, isChecked: true); + key: UniqueKey(), + style: defaultStyles!.leading!.style, + width: 32, + isChecked: true, + offset: block.offset + line.offset, + onTap: onCheckboxTap, + ); } if (attrs[Attribute.list.key] == Attribute.unchecked) { return _Checkbox( - style: defaultStyles!.leading!.style, width: 32, isChecked: false); + key: UniqueKey(), + style: defaultStyles!.leading!.style, + width: 32, + offset: block.offset + line.offset, + onTap: onCheckboxTap, + ); } if (attrs.containsKey(Attribute.codeBlock.key)) { @@ -685,46 +698,39 @@ class _BulletPoint extends StatelessWidget { } } -class _Checkbox extends StatefulWidget { - const _Checkbox({Key? key, this.style, this.width, this.isChecked}) - : super(key: key); - +class _Checkbox extends StatelessWidget { + const _Checkbox({ + Key? key, + this.style, + this.width, + this.isChecked = false, + this.offset, + this.onTap, + }) : super(key: key); final TextStyle? style; final double? width; - final bool? isChecked; + final bool isChecked; + final int? offset; + final Function(int, bool)? onTap; - @override - __CheckboxState createState() => __CheckboxState(); -} - -class __CheckboxState extends State<_Checkbox> { - bool? isChecked; - - void _onCheckboxClicked(bool? newValue) => setState(() { - isChecked = newValue; - - if (isChecked!) { - // check list - } else { - // uncheck list - } - }); - - @override - void initState() { - super.initState(); - isChecked = widget.isChecked; + void _onCheckboxClicked(bool? newValue) { + if (onTap != null && newValue != null && offset != null) { + onTap!(offset!, newValue); + } } @override Widget build(BuildContext context) { return Container( alignment: AlignmentDirectional.topEnd, - width: widget.width, + width: width, padding: const EdgeInsetsDirectional.only(end: 13), - child: Checkbox( - value: widget.isChecked, - onChanged: _onCheckboxClicked, + child: GestureDetector( + onLongPress: () => _onCheckboxClicked(!isChecked), + child: Checkbox( + value: isChecked, + onChanged: _onCheckboxClicked, + ), ), ); } diff --git a/lib/widgets/toolbar.dart b/lib/widgets/toolbar.dart index 8bcb3329..c5fe2bab 100644 --- a/lib/widgets/toolbar.dart +++ b/lib/widgets/toolbar.dart @@ -432,7 +432,8 @@ class _SelectHeaderStyleButtonState extends State { @override Widget build(BuildContext context) { - return _selectHeadingStyleButtonBuilder(context, _value, _selectAttribute, widget.controller.iconSize); + return _selectHeadingStyleButtonBuilder( + context, _value, _selectAttribute, widget.controller.iconSize); } } @@ -774,7 +775,8 @@ class _HistoryButtonState extends State { highlightElevation: 0, hoverElevation: 0, size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, size: widget.controller.iconSize, color: _iconColor), + icon: Icon(widget.icon, + size: widget.controller.iconSize, color: _iconColor), fillColor: fillColor, onPressed: _changeHistory, ); @@ -839,7 +841,8 @@ class _IndentButtonState extends State { highlightElevation: 0, hoverElevation: 0, size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), + icon: + Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), fillColor: fillColor, onPressed: () { final indent = widget.controller @@ -893,7 +896,8 @@ class _ClearFormatButtonState extends State { highlightElevation: 0, hoverElevation: 0, size: widget.controller.iconSize * 1.77, - icon: Icon(widget.icon, size: widget.controller.iconSize, color: iconColor), + icon: Icon(widget.icon, + size: widget.controller.iconSize, color: iconColor), fillColor: fillColor, onPressed: () { for (final k @@ -905,7 +909,9 @@ class _ClearFormatButtonState extends State { } class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { - const QuillToolbar({required this.children, this.toolBarHeight = 36, Key? key}) : super(key: key); + const QuillToolbar( + {required this.children, this.toolBarHeight = 36, Key? key}) + : super(key: key); factory QuillToolbar.basic({ required QuillController controller, @@ -932,178 +938,195 @@ class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { }) { 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, - ), - ), - ]); + 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; diff --git a/pubspec.yaml b/pubspec.yaml index 93bf71be..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.0 +version: 1.2.2 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill