diff --git a/CHANGELOG.md b/CHANGELOG.md index e86aea7f..c372d239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,139 @@ +# [3.0.4] +* Add maxContentWidth constraint to editor. + +# [3.0.3] +* Do not show caret on screen when the editor is not focused. + +# [3.0.2] +* Fix launch link for read-only mode. + +## [3.0.1] +* Handle null value of Attribute.link. + +## [3.0.0] +* Launch link improvements. +* Removed QuillSimpleViewer. + +## [2.5.2] +* Skip image when pasting. + +## [2.5.1] +* Bug fix for Desktop `Shift` + `Click` support. + +## [2.5.0] +* Update checkbox list. + +## [2.4.1] +* Desktop selection improvements. + +## [2.4.0] +* Improve inline code style. + +## [2.3.3] +* Improves selection rects to have consistent height regardless of individual segment text styles. + +## [2.3.2] +* Allow disabling floating cursor. + +## [2.3.1] +* Preserve last newline character on delete. + +## [2.3.0] +* Massive changes to support flutter 2.8. + +## [2.2.2] +* iOS - floating cursor. + +## [2.2.1] +* Bug fix for imports supporting flutter 2.8. + +## [2.2.0] +* Support flutter 2.8. + +## [2.1.1] +* Add methods of clearing editor and moving cursor. + +## [2.1.0] +* Add delete handler. + +## [2.0.23] +* Support custom replaceText handler. + +## [2.0.22] +* Fix attribute compare and fix font size parsing. + +## [2.0.21] +* Handle click on embed object. + +## [2.0.20] +* Improved UX/UI of Image widget. + +## [2.0.19] +* When uploading a video, applying indicator. + +## [2.0.18] +* Make toolbar dividers optional. + +## [2.0.17] +* Allow alignment of the toolbar icons to match WrapAlignment. + +## [2.0.16] +* Add hide / show alignment buttons. + +## [2.0.15] +* Implement change cursor to SystemMouseCursors.click when hovering a link styled text. + +## [2.0.14] +* Enable customize the checkbox widget using DefaultListBlockStyle style. + +## [2.0.13] +* Improve the scrolling performance by reducing the repaint areas. + +## [2.0.12] +* Fix the selection effect can't be seen as the textLine with background color. + +## [2.0.11] +* Fix visibility of text selection handlers on scroll. + +## [2.0.10] +* cursorConnt.color notify the text_line to repaint if it was disposed. + +## [2.0.9] +* Improve UX when trying to add a link. + +## [2.0.8] +* Adding translations to the toolbar. + +## [2.0.7] +* Added theming options for toolbar icons and LinkDialog. + +## [2.0.6] +* Avoid runtime error when placed inside TabBarView. + +## [2.0.5] +* Support inline code formatting. + +## [2.0.4] +* Enable history shortcuts for desktop. + +## [2.0.3] +* Fix cursor when line contains image. + +## [2.0.2] +* Address KeyboardListener class name conflict. + +## [2.0.1] +* Upgrade flutter_colorpicker to 0.5.0. + +## [2.0.0] +* Text Alignment functions + Block Format standards. + +## [1.9.6] +* Support putting QuillEditor inside a Scrollable view. + +## [1.9.5] +* Skip image when pasting. + ## [1.9.4] * Bug fix for cursor position when tapping at the end of line with image(s). diff --git a/README.md b/README.md index 4d5a2756..2a391da0 100644 --- a/README.md +++ b/README.md @@ -87,14 +87,14 @@ The `QuillToolbar` class lets you customise which formatting options are availab ## Web -For web development, use `flutter config --enable-web` for flutter and use [ReactQuill] for React. +For web development, use `flutter config --enable-web` for flutter or use [ReactQuill] for React. It is required to provide `EmbedBuilder`, e.g. [defaultEmbedBuilderWeb](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/universal_ui/universal_ui.dart#L28). -Also it is required to provide `webImagePickImpl`, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L212). +Also it is required to provide `webImagePickImpl`, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L218). ## Desktop -It is required to provide `filePickImpl` for toolbar image button, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L192). +It is required to provide `filePickImpl` for toolbar image button, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L198). ## Custom Size Image for Mobile @@ -110,9 +110,26 @@ Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follo } ``` -## 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). +## Translation of toolbar +The package offers translations for the quill toolbar, it will follow the system locale unless you set your own locale with: +``` +QuillToolbar(locale: Locale('fr'), ...) +``` +Currently, translations are available for these locales: +* `Locale('en')` +* `Locale('ar')` +* `Locale('de')` +* `Locale('da')` +* `Locale('fr')` +* `Locale('zh', 'CN')` +* `Locale('ko')` +* `Locale('ru')` +* `Locale('es')` +* `Locale('tr')` +* `Locale('uk')` + +### Contributing to translations +The translation file is located at [lib/src/translations/toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart). Feel free to contribute your own translations, just copy the English translations map and replace the values with your translations. Then open a pull request so everyone can benefit from your translations! --- diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 8abf61d9..36acd869 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -149,18 +149,23 @@ class _HomePageState extends State { onVideoPickCallback: _onVideoPickCallback, // uncomment to provide a custom "pick from" dialog. // mediaPickSettingSelector: _selectMediaPickSetting, + showAlignmentButtons: true, ); if (kIsWeb) { toolbar = QuillToolbar.basic( - controller: _controller!, - onImagePickCallback: _onImagePickCallback, - webImagePickImpl: _webImagePickImpl); + controller: _controller!, + onImagePickCallback: _onImagePickCallback, + webImagePickImpl: _webImagePickImpl, + showAlignmentButtons: true, + ); } if (_isDesktop()) { toolbar = QuillToolbar.basic( - controller: _controller!, - onImagePickCallback: _onImagePickCallback, - filePickImpl: openFileSystemPickerForDesktop); + controller: _controller!, + onImagePickCallback: _onImagePickCallback, + filePickImpl: openFileSystemPickerForDesktop, + showAlignmentButtons: true, + ); } return SafeArea( diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements index 51d09670..d6c8ee0c 100644 --- a/example/macos/Runner/DebugProfile.entitlements +++ b/example/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index bf233450..ca3ecf17 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -5,9 +5,9 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:app/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:app/main.dart'; void main() { testWidgets('Counter increments smoke test', (tester) async { diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index ddfcf7c3..92772e9f 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -4,9 +4,9 @@ #include "generated_plugin_registrant.h" -#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { - UrlLauncherPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 6b6754ce..8b0a76ee 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -5,7 +5,11 @@ 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/models/themes/quill_dialog_theme.dart'; +export 'src/models/themes/quill_icon_theme.dart'; export 'src/widgets/controller.dart'; export 'src/widgets/default_styles.dart'; export 'src/widgets/editor.dart'; +export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction; +export 'src/widgets/style_widgets/style_widgets.dart'; export 'src/widgets/toolbar.dart'; diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index 97d82f15..ecf05e26 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -19,8 +19,10 @@ class Attribute { static final Map _registry = LinkedHashMap.of({ Attribute.bold.key: Attribute.bold, Attribute.italic.key: Attribute.italic, + Attribute.small.key: Attribute.small, Attribute.underline.key: Attribute.underline, Attribute.strikeThrough.key: Attribute.strikeThrough, + Attribute.inlineCode.key: Attribute.inlineCode, Attribute.font.key: Attribute.font, Attribute.size.key: Attribute.size, Attribute.link.key: Attribute.link, @@ -37,16 +39,21 @@ class Attribute { Attribute.height.key: Attribute.height, Attribute.style.key: Attribute.style, Attribute.token.key: Attribute.token, + Attribute.script.key: Attribute.script, }); static final BoldAttribute bold = BoldAttribute(); static final ItalicAttribute italic = ItalicAttribute(); + static final SmallAttribute small = SmallAttribute(); + static final UnderlineAttribute underline = UnderlineAttribute(); static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); + static final InlineCodeAttribute inlineCode = InlineCodeAttribute(); + static final FontAttribute font = FontAttribute(null); static final SizeAttribute size = SizeAttribute(null); @@ -79,9 +86,12 @@ class Attribute { static final TokenAttribute token = TokenAttribute(''); + static final ScriptAttribute script = ScriptAttribute(''); + static final Set inlineKeys = { Attribute.bold.key, Attribute.italic.key, + Attribute.small.key, Attribute.underline.key, Attribute.strikeThrough.key, Attribute.link.key, @@ -107,6 +117,13 @@ class Attribute { Attribute.indent.key, }); + static final Set exclusiveBlockKeys = LinkedHashSet.of({ + Attribute.header.key, + Attribute.list.key, + Attribute.codeBlock.key, + Attribute.blockQuote.key, + }); + static Attribute get h1 => HeaderAttribute(level: 1); static Attribute get h2 => HeaderAttribute(level: 2); @@ -217,6 +234,10 @@ class ItalicAttribute extends Attribute { ItalicAttribute() : super('italic', AttributeScope.INLINE, true); } +class SmallAttribute extends Attribute { + SmallAttribute() : super('small', AttributeScope.INLINE, true); +} + class UnderlineAttribute extends Attribute { UnderlineAttribute() : super('underline', AttributeScope.INLINE, true); } @@ -225,6 +246,10 @@ class StrikeThroughAttribute extends Attribute { StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true); } +class InlineCodeAttribute extends Attribute { + InlineCodeAttribute() : super('code', AttributeScope.INLINE, true); +} + class FontAttribute extends Attribute { FontAttribute(String? val) : super('font', AttributeScope.INLINE, val); } @@ -290,3 +315,8 @@ class StyleAttribute extends Attribute { class TokenAttribute extends Attribute { TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); } + +// `script` is supposed to be inline attribute but it is not supported yet +class ScriptAttribute extends Attribute { + ScriptAttribute(String val) : super('script', AttributeScope.IGNORE, val); +} diff --git a/lib/src/models/documents/nodes/container.dart b/lib/src/models/documents/nodes/container.dart index 40d3ba9e..ac13ff27 100644 --- a/lib/src/models/documents/nodes/container.dart +++ b/lib/src/models/documents/nodes/container.dart @@ -94,7 +94,7 @@ abstract class Container extends Node { for (final node in children) { final len = node.length; - if (offset < len || (inclusive && offset == len && node.isLast)) { + if (offset < len || (inclusive && offset == len)) { return ChildQuery(node, offset); } offset -= len; diff --git a/lib/src/models/documents/nodes/embed.dart b/lib/src/models/documents/nodes/embed.dart index 18c550fe..66a3013f 100644 --- a/lib/src/models/documents/nodes/embed.dart +++ b/lib/src/models/documents/nodes/embed.dart @@ -19,7 +19,7 @@ class Embeddable { static Embeddable fromJson(Map json) { final m = Map.from(json); - assert(m.length == 1, 'Embeddable map has one key'); + assert(m.length == 1, 'Embeddable map must only have one key'); return BlockEmbed(m.keys.first, m.values.first); } diff --git a/lib/src/models/documents/nodes/line.dart b/lib/src/models/documents/nodes/line.dart index fa5f6c90..2476192a 100644 --- a/lib/src/models/documents/nodes/line.dart +++ b/lib/src/models/documents/nodes/line.dart @@ -202,11 +202,26 @@ class Line extends Container { if (parent is Block) { final parentStyle = (parent as Block).style.getBlocksExceptHeader(); - if (blockStyle.value == null) { + // Ensure that we're only unwrapping the block only if we unset a single + // block format in the `parentStyle` and there are no more block formats + // left to unset. + if (blockStyle.value == null && + parentStyle.containsKey(blockStyle.key) && + parentStyle.length == 1) { _unwrap(); } else if (!const MapEquality() .equals(newStyle.getBlocksExceptHeader(), parentStyle)) { _unwrap(); + // Block style now can contain multiple attributes + if (newStyle.attributes.keys + .any(Attribute.exclusiveBlockKeys.contains)) { + parentStyle.removeWhere( + (key, attr) => Attribute.exclusiveBlockKeys.contains(key)); + } + parentStyle.removeWhere( + (key, attr) => newStyle?.attributes.keys.contains(key) ?? false); + final parentStyleToMerge = Style.attr(parentStyle); + newStyle = newStyle.mergeAll(parentStyleToMerge); _applyBlockStyles(newStyle); } // else the same style, no-op. } else if (blockStyle.value != null) { @@ -344,8 +359,8 @@ class Line extends Container { result = result.mergeAll(node.style); var pos = node.length - data.offset; while (!node!.isLast && pos < local) { - node = node.next as Leaf?; - _handle(node!.style); + node = node.next as Leaf; + _handle(node.style); pos += node.length; } } @@ -376,8 +391,8 @@ class Line extends Container { result.add(node.style); var pos = node.length - data.offset; while (!node!.isLast && pos < local) { - node = node.next as Leaf?; - result.add(node!.style); + node = node.next as Leaf; + result.add(node.style); pos += node.length; } } diff --git a/lib/src/models/documents/nodes/node.dart b/lib/src/models/documents/nodes/node.dart index 08335727..6805a3be 100644 --- a/lib/src/models/documents/nodes/node.dart +++ b/lib/src/models/documents/nodes/node.dart @@ -14,7 +14,8 @@ import 'line.dart'; /// 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. +/// The current parent node is exposed by the [parent] property. A node is +/// considered [mounted] when the [parent] property is not `null`. abstract class Node extends LinkedListEntry { /// Current parent of this node. May be null if this node is not mounted. Container? parent; diff --git a/lib/src/models/quill_delta.dart b/lib/src/models/quill_delta.dart index 47bbde2a..3cab1910 100644 --- a/lib/src/models/quill_delta.dart +++ b/lib/src/models/quill_delta.dart @@ -150,6 +150,11 @@ class Operation { /// Returns `true` if [other] operation has the same attributes as this one. bool hasSameAttributes(Operation other) { + // treat null and empty equal + if ((_attributes?.isEmpty ?? true) && + (other._attributes?.isEmpty ?? true)) { + return true; + } return _attributeEquality.equals(_attributes, other._attributes); } @@ -603,9 +608,28 @@ class Delta { } } + /// Removes trailing '\n' + void _trimNewLine() { + if (isNotEmpty) { + final lastOp = _operations.last; + final lastOpData = lastOp.data; + + if (lastOpData is String && lastOpData.endsWith('\n')) { + _operations.removeLast(); + if (lastOpData.length > 1) { + insert(lastOpData.substring(0, lastOpData.length - 1), + lastOp.attributes); + } + } + } + } + /// Concatenates [other] with this delta and returns the result. - Delta concat(Delta other) { + Delta concat(Delta other, {bool trimNewLine = false}) { final result = Delta.from(this); + if (trimNewLine) { + result._trimNewLine(); + } if (other.isNotEmpty) { // In case first operation of other can be merged with last operation in // our list. diff --git a/lib/src/models/rules/delete.dart b/lib/src/models/rules/delete.dart index e6682f94..caf8cf6a 100644 --- a/lib/src/models/rules/delete.dart +++ b/lib/src/models/rules/delete.dart @@ -2,6 +2,7 @@ import '../documents/attribute.dart'; import '../quill_delta.dart'; import 'rule.dart'; +/// A heuristic rule for delete operations. abstract class DeleteRule extends Rule { const DeleteRule(); @@ -16,18 +17,42 @@ abstract class DeleteRule extends Rule { } } +class EnsureLastLineBreakDeleteRule extends DeleteRule { + const EnsureLastLineBreakDeleteRule(); + + @override + Delta? applyRule(Delta document, int index, + {int? len, Object? data, Attribute? attribute}) { + final itr = DeltaIterator(document)..skip(index + len!); + + return Delta() + ..retain(index) + ..delete(itr.hasNext ? len : len - 1); + } +} + +/// Fallback rule for delete operations which simply deletes specified text +/// range without any special handling. class CatchAllDeleteRule extends DeleteRule { const CatchAllDeleteRule(); @override Delta applyRule(Delta document, int index, {int? len, Object? data, Attribute? attribute}) { + final itr = DeltaIterator(document)..skip(index + len!); + return Delta() ..retain(index) - ..delete(len!); + ..delete(itr.hasNext ? len : len - 1); } } +/// Preserves line format when user deletes the line's newline character +/// effectively merging it with the next line. +/// +/// This rule makes sure to apply all style attributes of deleted newline +/// to the next available newline, which may reset any style attributes +/// already present there. class PreserveLineStyleOnMergeRule extends DeleteRule { const PreserveLineStyleOnMergeRule(); @@ -44,6 +69,14 @@ class PreserveLineStyleOnMergeRule extends DeleteRule { final attrs = op.attributes; itr.skip(len! - 1); + + if (!itr.hasNext) { + // User attempts to delete the last newline character, prevent it. + return Delta() + ..retain(index) + ..delete(len - 1); + } + final delta = Delta() ..retain(index) ..delete(len); @@ -66,13 +99,16 @@ class PreserveLineStyleOnMergeRule extends DeleteRule { attributes ??= {}; attributes.addAll(attrs!); } - delta..retain(lineBreak)..retain(1, attributes); + delta + ..retain(lineBreak) + ..retain(1, attributes); break; } return delta; } } +/// Prevents user from merging a line containing an embed with other lines. class EnsureEmbedLineRule extends DeleteRule { const EnsureEmbedLineRule(); diff --git a/lib/src/models/rules/format.dart b/lib/src/models/rules/format.dart index be201925..e1833b7c 100644 --- a/lib/src/models/rules/format.dart +++ b/lib/src/models/rules/format.dart @@ -2,6 +2,7 @@ import '../documents/attribute.dart'; import '../quill_delta.dart'; import 'rule.dart'; +/// A heuristic rule for format (retain) operations. abstract class FormatRule extends Rule { const FormatRule(); @@ -16,6 +17,8 @@ abstract class FormatRule extends Rule { } } +/// Produces Delta with line-level attributes applied strictly to +/// newline characters. class ResolveLineFormatRule extends FormatRule { const ResolveLineFormatRule(); @@ -26,44 +29,81 @@ class ResolveLineFormatRule extends FormatRule { return null; } - var delta = Delta()..retain(index); + // Apply line styles to all newline characters within range of this + // retain operation. + var result = 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!); + final opText = op.data is String ? op.data as String : ''; + if (!opText.contains('\n')) { + result.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); - } + final delta = _applyAttribute(opText, op, attribute); + result = result.concat(delta); + } + // And include extra newline after retain 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!); + final opText = op.data is String ? op.data as String : ''; + final lf = opText.indexOf('\n'); + if (lf < 0) { + result.retain(op.length!); continue; } - delta..retain(lineBreak)..retain(1, attribute.toJson()); + + final delta = _applyAttribute(opText, op, attribute, firstOnly: true); + result = result.concat(delta); break; } - return delta; + return result; + } + + Delta _applyAttribute(String text, Operation op, Attribute attribute, + {bool firstOnly = false}) { + final result = Delta(); + var offset = 0; + var lf = text.indexOf('\n'); + final removedBlocks = _getRemovedBlocks(attribute, op); + while (lf >= 0) { + final actualStyle = attribute.toJson()..addEntries(removedBlocks); + result + ..retain(lf - offset) + ..retain(1, actualStyle); + + if (firstOnly) { + return result; + } + + offset = lf + 1; + lf = text.indexOf('\n', offset); + } + // Retain any remaining characters in text + result.retain(text.length - offset); + return result; + } + + Iterable> _getRemovedBlocks( + Attribute attribute, Operation op) { + // Enforce Block Format exclusivity by rule + if (!Attribute.exclusiveBlockKeys.contains(attribute.key)) { + return >[]; + } + + return op.attributes?.keys + .where((key) => + Attribute.exclusiveBlockKeys.contains(key) && + attribute.key != key && + attribute.value != null) + .map((key) => MapEntry(key, null)) ?? + []; } } +/// Allows updating link format with collapsed selection. class FormatLinkAtCaretPositionRule extends FormatRule { const FormatLinkAtCaretPositionRule(); @@ -89,11 +129,15 @@ class FormatLinkAtCaretPositionRule extends FormatRule { return null; } - delta..retain(beg)..retain(retain!, attribute.toJson()); + delta + ..retain(beg) + ..retain(retain!, attribute.toJson()); return delta; } } +/// Produces Delta with inline-level attributes applied too all characters +/// except newlines. class ResolveInlineFormatRule extends FormatRule { const ResolveInlineFormatRule(); @@ -118,7 +162,9 @@ class ResolveInlineFormatRule extends FormatRule { } var pos = 0; while (lineBreak >= 0) { - delta..retain(lineBreak - pos, attribute.toJson())..retain(1); + delta + ..retain(lineBreak - pos, attribute.toJson()) + ..retain(1); pos = lineBreak + 1; lineBreak = text.indexOf('\n', pos); } diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index 892b3e8a..57b7117f 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -5,6 +5,7 @@ import '../documents/style.dart'; import '../quill_delta.dart'; import 'rule.dart'; +/// A heuristic rule for insert operations. abstract class InsertRule extends Rule { const InsertRule(); @@ -18,6 +19,10 @@ abstract class InsertRule extends Rule { } } +/// Preserves line format when user splits the line into two. +/// +/// This rule ignores scenarios when the line is split on its edge, meaning +/// a newline is inserted at the beginning or the end of a line. class PreserveLineStyleOnSplitRule extends InsertRule { const PreserveLineStyleOnSplitRule(); @@ -87,12 +92,12 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { return null; } - Map? resetStyle; + final 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(); + resetStyle.addAll(Attribute.header.toJson()); } // Go over each inserted line and ensure block style is applied. @@ -113,7 +118,7 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { } // Reset style of the original newline character if needed. - if (resetStyle != null) { + if (resetStyle.isNotEmpty) { delta ..retain(nextNewLine.item2!) ..retain((nextNewLine.item1!.data as String).indexOf('\n')) @@ -192,10 +197,17 @@ class AutoExitBlockRule extends InsertRule { 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); + return Delta() + ..retain(index + (len ?? 0)) + ..retain(1, attributes); } } +/// Resets format for a newly inserted line when insert occurred at the end +/// of a line (right before a newline). +/// +/// This handles scenarios when a new line is added when at the end of a +/// heading line. The newly added line should be a regular paragraph. class ResetLineFormatOnNewLineRule extends InsertRule { const ResetLineFormatOnNewLineRule(); @@ -225,6 +237,7 @@ class ResetLineFormatOnNewLineRule extends InsertRule { } } +/// Handles all format operations which manipulate embeds. class InsertEmbedsRule extends InsertRule { const InsertEmbedsRule(); @@ -273,6 +286,8 @@ class InsertEmbedsRule extends InsertRule { } } +/// Applies link format to text segment (which looks like a link) when user +/// inserts space character after it. class AutoFormatLinksRule extends InsertRule { const AutoFormatLinksRule(); @@ -312,6 +327,7 @@ class AutoFormatLinksRule extends InsertRule { } } +/// Preserves inline styles when user inserts text inside formatted segment. class PreserveInlineStylesRule extends InsertRule { const PreserveInlineStylesRule(); @@ -357,6 +373,7 @@ class PreserveInlineStylesRule extends InsertRule { } } +/// Fallback rule which simply inserts text as-is without any special handling. class CatchAllInsertRule extends InsertRule { const CatchAllInsertRule(); diff --git a/lib/src/models/rules/rule.dart b/lib/src/models/rules/rule.dart index a2649499..f01eb438 100644 --- a/lib/src/models/rules/rule.dart +++ b/lib/src/models/rules/rule.dart @@ -19,6 +19,8 @@ abstract class Rule { void validateArgs(int? len, Object? data, Attribute? attribute); + /// Applies heuristic rule to an operation on a [document] and returns + /// resulting [Delta]. Delta? applyRule(Delta document, int index, {int? len, Object? data, Attribute? attribute}); @@ -46,6 +48,7 @@ class Rules { const EnsureEmbedLineRule(), const PreserveLineStyleOnMergeRule(), const CatchAllDeleteRule(), + const EnsureLastLineBreakDeleteRule() ]); static Rules getInstance() => _instance; diff --git a/lib/src/models/themes/quill_dialog_theme.dart b/lib/src/models/themes/quill_dialog_theme.dart new file mode 100644 index 00000000..795d35d5 --- /dev/null +++ b/lib/src/models/themes/quill_dialog_theme.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class QuillDialogTheme { + QuillDialogTheme( + {this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor}); + + ///The text style to use for the label shown in the link-input dialog + final TextStyle? labelTextStyle; + + ///The text style to use for the input text shown in the link-input dialog + final TextStyle? inputTextStyle; + + ///The background color for the [LinkDialog()] + final Color? dialogBackgroundColor; +} diff --git a/lib/src/models/themes/quill_icon_theme.dart b/lib/src/models/themes/quill_icon_theme.dart new file mode 100644 index 00000000..58f5998b --- /dev/null +++ b/lib/src/models/themes/quill_icon_theme.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class QuillIconTheme { + const QuillIconTheme({ + this.iconSelectedColor, + this.iconUnselectedColor, + this.iconSelectedFillColor, + this.iconUnselectedFillColor, + this.disabledIconColor, + this.disabledIconFillColor, + }); + + ///The color to use for selected icons in the toolbar + final Color? iconSelectedColor; + + ///The color to use for unselected icons in the toolbar + final Color? iconUnselectedColor; + + ///The fill color to use for the selected icons in the toolbar + final Color? iconSelectedFillColor; + + ///The fill color to use for the unselected icons in the toolbar + final Color? iconUnselectedFillColor; + + ///The color to use for disabled icons in the toolbar + final Color? disabledIconColor; + + ///The fill color to use for disabled icons in the toolbar + final Color? disabledIconFillColor; +} diff --git a/lib/src/translations/toolbar.i18n.dart b/lib/src/translations/toolbar.i18n.dart new file mode 100644 index 00000000..8984da73 --- /dev/null +++ b/lib/src/translations/toolbar.i18n.dart @@ -0,0 +1,110 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale('en') + + { + 'en': { + 'Paste a link': 'Paste a link', + 'Ok': 'Ok', + 'Select Color': 'Select Color', + 'Gallery': 'Gallery', + 'Link': 'Link', + 'Please first select some text to transform into a link.': + 'Please first select some text to transform into a link.', + }, + 'ar': { + 'Paste a link': 'نسخ الرابط', + 'Ok': 'نعم', + 'Select Color': 'اختار اللون', + 'Gallery': 'الصور', + 'Link': 'الرابط', + 'Please first select some text to transform into a link.': + 'يرجى اختيار نص للتحويل إلى رابط', + }, + 'da': { + 'Paste a link': 'Indsæt link', + 'Ok': 'Ok', + 'Select Color': 'Vælg farve', + 'Gallery': 'Galleri', + 'Link': 'Link', + 'Please first select some text to transform into a link.': + 'Vælg venligst først noget tekst for at lave det om til et link.', + }, + 'de': { + 'Paste a link': 'Link hinzufügen', + 'Ok': 'Ok', + 'Select Color': 'Farbe auswählen', + 'Gallery': 'Gallerie', + 'Link': 'Link', + 'Please first select some text to transform into a link.': + 'Markiere bitte zuerst einen Text, um diesen in einen Link zu ' + 'verwandeln.', + }, + 'fr': { + 'Paste a link': 'Coller un lien', + 'Ok': 'Ok', + 'Select Color': 'Choisir une couleur', + 'Gallery': 'Galerie', + 'Link': 'Lien', + 'Please first select some text to transform into a link.': + "Veuillez d'abord sélectionner un texte à transformer en lien.", + }, + 'zh_CN': { + 'Paste a link': '粘贴链接', + 'Ok': '好', + 'Select Color': '选择颜色', + 'Gallery': '相簿', + 'Link': '链接', + 'Please first select some text to transform into a link.': + '请先选择一些要转化为链接的文本', + }, + 'ko': { + 'Paste a link': '링크를 붙여넣어 주세요.', + 'Ok': '확인', + 'Select Color': '색상 선택', + 'Gallery': '갤러리', + 'Link': '링크', + 'Please first select some text to transform into a link.': + '링크로 전환할 글자를 먼저 선택해주세요.', + }, + 'ru': { + 'Paste a link': 'Вставить ссылку', + 'Ok': 'ОК', + 'Select Color': 'Выбрать цвет', + 'Gallery': 'Галерея', + 'Link': 'Ссылка', + 'Please first select some text to transform into a link.': + 'Выделите часть текста для создания ссылки.', + }, + 'es': { + 'Paste a link': 'Pega un enlace', + 'Ok': 'Ok', + 'Select Color': 'Selecciona un color', + 'Gallery': 'Galeria', + 'Link': 'Enlace', + 'Please first select some text to transform into a link.': + 'Por favor selecciona primero un texto para transformarlo ' + 'en un enlace', + }, + 'tr': { + 'Paste a link': 'Bağlantıyı Yapıştır', + 'Ok': 'Tamam', + 'Select Color': 'Renk Seçin', + 'Gallery': 'Galeri', + 'Link': 'Bağlantı', + 'Please first select some text to transform into a link.': + 'Lütfen bağlantıya dönüştürmek için bir metin seçin.', + }, + 'uk': { + 'Paste a link': 'Вставити посилання', + 'Ok': 'ОК', + 'Select Color': 'Вибрати колір', + 'Gallery': 'Галерея', + 'Link': 'Посилання', + 'Please first select some text to transform into a link.': + 'Виділіть текст для створення посилання.', + }, + }; + + String get i18n => localize(this, _t); +} diff --git a/lib/src/utils/color.dart b/lib/src/utils/color.dart index 93b6e12b..dc3b5fa5 100644 --- a/lib/src/utils/color.dart +++ b/lib/src/utils/color.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; Color stringToColor(String? s) { diff --git a/lib/src/utils/diff_delta.dart b/lib/src/utils/diff_delta.dart index f44a1a09..cf0e5c63 100644 --- a/lib/src/utils/diff_delta.dart +++ b/lib/src/utils/diff_delta.dart @@ -2,35 +2,6 @@ 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); diff --git a/lib/src/utils/string_helper.dart b/lib/src/utils/string_helper.dart index 44abfd8a..e3d8b7e0 100644 --- a/lib/src/utils/string_helper.dart +++ b/lib/src/utils/string_helper.dart @@ -1,3 +1,5 @@ +import 'package:flutter/cupertino.dart'; + Map parseKeyValuePairs(String s, Set targetKeys) { final result = {}; final pairs = s.split(';'); @@ -14,3 +16,37 @@ Map parseKeyValuePairs(String s, Set targetKeys) { return result; } + +Alignment getAlignment(String? s) { + const _defaultAlignment = Alignment.center; + if (s == null) { + return _defaultAlignment; + } + + final _index = [ + 'topLeft', + 'topCenter', + 'topRight', + 'centerLeft', + 'center', + 'centerRight', + 'bottomLeft', + 'bottomCenter', + 'bottomRight' + ].indexOf(s); + if (_index < 0) { + return _defaultAlignment; + } + + return [ + Alignment.topLeft, + Alignment.topCenter, + Alignment.topRight, + Alignment.centerLeft, + Alignment.center, + Alignment.centerRight, + Alignment.bottomLeft, + Alignment.bottomCenter, + Alignment.bottomRight + ][_index]; +} diff --git a/lib/src/widgets/box.dart b/lib/src/widgets/box.dart index 566c1a92..b754b5c2 100644 --- a/lib/src/widgets/box.dart +++ b/lib/src/widgets/box.dart @@ -119,4 +119,8 @@ abstract class RenderEditableBox extends RenderBox { /// Returns the [Rect] in local coordinates for the caret at the given text /// position. Rect getLocalRectForCaret(TextPosition position); + + /// Returns the [Rect] of the caret prototype at the given text + /// position. [Rect] starts at origin. + Rect getCaretPrototype(TextPosition position); } diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index f26765b0..5ef115c8 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -10,11 +10,18 @@ import '../models/documents/style.dart'; import '../models/quill_delta.dart'; import '../utils/diff_delta.dart'; +typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); +typedef DeleteCallback = void Function(int cursorPosition, bool forward); + class QuillController extends ChangeNotifier { QuillController({ required this.document, required TextSelection selection, - }) : _selection = selection; + bool keepStyleOnNewLine = false, + this.onReplaceText, + this.onDelete, + }) : _selection = selection, + _keepStyleOnNewLine = keepStyleOnNewLine; factory QuillController.basic() { return QuillController( @@ -26,10 +33,21 @@ class QuillController extends ChangeNotifier { /// Document managed by this controller. final Document document; + /// Tells whether to keep or reset the [toggledStyle] + /// when user adds a new line. + final bool _keepStyleOnNewLine; + /// Currently selected text within the [document]. TextSelection get selection => _selection; TextSelection _selection; + /// Custom [replaceText] handler + /// Return false to ignore the event + ReplaceTextCallback? onReplaceText; + + /// Custom delete handler + DeleteCallback? onDelete; + /// 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]. @@ -79,7 +97,7 @@ class QuillController extends ChangeNotifier { } void _handleHistoryChange(int? len) { - if (len! > 0) { + if (len! != 0) { // if (this.selection.extentOffset >= document.length) { // // cursor exceeds the length of document, position it in the end // updateSelection( @@ -104,11 +122,21 @@ class QuillController extends ChangeNotifier { bool get hasRedo => document.hasRedo; + /// clear editor + void clear() { + replaceText(0, plainTextEditingValue.text.length - 1, '', + const TextSelection.collapsed(offset: 0)); + } + void replaceText( int index, int len, Object? data, TextSelection? textSelection, {bool ignoreFocus = false}) { assert(data is String || data is Embeddable); + if (onReplaceText != null && !onReplaceText!(index, len, data)) { + return; + } + Delta? delta; if (len > 0 || data is! String || data.isNotEmpty) { delta = document.replace(index, len, data); @@ -135,7 +163,14 @@ class QuillController extends ChangeNotifier { } } - toggledStyle = Style(); + if (_keepStyleOnNewLine) { + final style = getSelectionStyle(); + final notInlineStyle = style.attributes.values.where((s) => !s.isInline); + toggledStyle = style.removeAll(notInlineStyle.toSet()); + } else { + toggledStyle = Style(); + } + if (textSelection != null) { if (delta == null || delta.isEmpty) { _updateSelection(textSelection, ChangeSource.LOCAL); @@ -162,6 +197,14 @@ class QuillController extends ChangeNotifier { ignoreFocusOnTextChange = false; } + /// Called in two cases: + /// forward == false && textBefore.isEmpty + /// forward == true && textAfter.isEmpty + /// Android only + /// see https://github.com/singerdmx/flutter-quill/discussions/514 + void handleDelete(int cursorPosition, bool forward) => + onDelete?.call(cursorPosition, forward); + void formatText(int index, int len, Attribute? attribute) { if (len == 0 && attribute!.isInline && @@ -183,6 +226,17 @@ class QuillController extends ChangeNotifier { formatText(selection.start, selection.end - selection.start, attribute); } + void moveCursorToStart() { + updateSelection( + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); + } + + void moveCursorToEnd() { + updateSelection( + TextSelection.collapsed(offset: plainTextEditingValue.text.length), + ChangeSource.LOCAL); + } + void updateSelection(TextSelection textSelection, ChangeSource source) { _updateSelection(textSelection, source); notifyListeners(); diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 2d13aff0..92666d82 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -131,6 +131,17 @@ class CursorCont extends ChangeNotifier { Timer? _cursorTimer; bool _targetCursorVisibility = false; + final ValueNotifier _floatingCursorTextPosition = + ValueNotifier(null); + + ValueNotifier get floatingCursorTextPosition => + _floatingCursorTextPosition; + + void setFloatingCursorTextPosition(TextPosition? position) => + _floatingCursorTextPosition.value = position; + + bool get isFloatingCursorActive => floatingCursorTextPosition.value != null; + CursorStyle _style; CursorStyle get style => _style; set style(CursorStyle value) { @@ -228,13 +239,13 @@ class CursorCont extends ChangeNotifier { /// Paints the editing cursor. class CursorPainter { - CursorPainter( - this.editable, - this.style, - this.prototype, - this.color, - this.devicePixelRatio, - ); + CursorPainter({ + required this.editable, + required this.style, + required this.prototype, + required this.color, + required this.devicePixelRatio, + }); final RenderContentProxyBox? editable; final CursorStyle style; @@ -245,10 +256,11 @@ class CursorPainter { /// Paints cursor on [canvas] at specified [position]. /// [offset] is global top left (x, y) of text line /// [position] is relative (x) in text line - void paint(Canvas canvas, Offset offset, TextPosition position) { + void paint( + Canvas canvas, Offset offset, TextPosition position, bool lineHasEmbed) { // relative (x, y) to global offset var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype); - if (relativeCaretOffset == Offset.zero) { + if (lineHasEmbed && relativeCaretOffset == Offset.zero) { relativeCaretOffset = editable!.getOffsetForCaret( TextPosition( offset: position.offset - 1, affinity: position.affinity), @@ -257,6 +269,7 @@ class CursorPainter { relativeCaretOffset = Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy); } + final caretOffset = relativeCaretOffset + offset; var caretRect = prototype.shift(caretOffset); if (style.offset != null) { diff --git a/lib/src/widgets/default_styles.dart b/lib/src/widgets/default_styles.dart index 1cebe135..2edc2088 100644 --- a/lib/src/widgets/default_styles.dart +++ b/lib/src/widgets/default_styles.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:tuple/tuple.dart'; +import '../../flutter_quill.dart'; +import '../../models/documents/style.dart'; + class QuillStyles extends InheritedWidget { const QuillStyles({ required this.data, @@ -26,6 +28,8 @@ class QuillStyles extends InheritedWidget { } } +/// Style theme applied to a block of rich text, including single-line +/// paragraphs. class DefaultTextBlockStyle { DefaultTextBlockStyle( this.style, @@ -34,15 +38,100 @@ class DefaultTextBlockStyle { this.decoration, ); + /// Base text style for a text block. final TextStyle style; + /// Vertical spacing around a text block. final Tuple2 verticalSpacing; + /// Vertical spacing for individual lines within a text block. + /// final Tuple2 lineSpacing; + /// Decoration of a text block. + /// + /// Decoration, if present, is painted in the content area, excluding + /// any [spacing]. final BoxDecoration? decoration; } +/// Theme data for inline code. +class InlineCodeStyle { + InlineCodeStyle({ + required this.style, + this.header1, + this.header2, + this.header3, + this.backgroundColor, + this.radius, + }); + + /// Base text style for an inline code. + final TextStyle style; + + /// Style override for inline code in header level 1. + final TextStyle? header1; + + /// Style override for inline code in headings level 2. + final TextStyle? header2; + + /// Style override for inline code in headings level 3. + final TextStyle? header3; + + /// Background color for inline code. + final Color? backgroundColor; + + /// Radius used when paining the background. + final Radius? radius; + + /// Returns effective style to use for inline code for the specified + /// [lineStyle]. + TextStyle styleFor(Style lineStyle) { + if (lineStyle.containsKey(Attribute.h1.key)) { + return header1 ?? style; + } + if (lineStyle.containsKey(Attribute.h2.key)) { + return header2 ?? style; + } + if (lineStyle.containsKey(Attribute.h3.key)) { + return header3 ?? style; + } + return style; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! InlineCodeStyle) { + return false; + } + return other.style == style && + other.header1 == header1 && + other.header2 == header2 && + other.header3 == header3 && + other.backgroundColor == backgroundColor && + other.radius == radius; + } + + @override + int get hashCode => + Object.hash(style, header1, header2, header3, backgroundColor, radius); +} + +class DefaultListBlockStyle extends DefaultTextBlockStyle { + DefaultListBlockStyle( + TextStyle style, + Tuple2 verticalSpacing, + Tuple2 lineSpacing, + BoxDecoration? decoration, + this.checkboxUIBuilder, + ) : super(style, verticalSpacing, lineSpacing, decoration); + + final QuillCheckboxBuilder? checkboxUIBuilder; +} + class DefaultStyles { DefaultStyles({ this.h1, @@ -51,8 +140,10 @@ class DefaultStyles { this.paragraph, this.bold, this.italic, + this.small, this.underline, this.strikeThrough, + this.inlineCode, this.link, this.color, this.placeHolder, @@ -73,15 +164,19 @@ class DefaultStyles { final DefaultTextBlockStyle? paragraph; final TextStyle? bold; final TextStyle? italic; + final TextStyle? small; final TextStyle? underline; final TextStyle? strikeThrough; + + /// Theme of inline code. + final InlineCodeStyle? inlineCode; 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 DefaultListBlockStyle? lists; final DefaultTextBlockStyle? quote; final DefaultTextBlockStyle? code; final DefaultTextBlockStyle? indent; @@ -112,6 +207,12 @@ class DefaultStyles { throw UnimplementedError(); } + final inlineCodeStyle = TextStyle( + fontSize: 14, + color: themeData.colorScheme.primaryVariant.withOpacity(0.8), + fontFamily: fontFamily, + ); + return DefaultStyles( h1: DefaultTextBlockStyle( defaultTextStyle.style.copyWith( @@ -147,10 +248,25 @@ class DefaultStyles { baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), bold: const TextStyle(fontWeight: FontWeight.bold), italic: const TextStyle(fontStyle: FontStyle.italic), + small: const TextStyle(fontSize: 12, color: Colors.black45), underline: const TextStyle(decoration: TextDecoration.underline), strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough), + inlineCode: InlineCodeStyle( + backgroundColor: Colors.grey.shade100, + radius: const Radius.circular(3), + style: inlineCodeStyle, + header1: inlineCodeStyle.copyWith( + fontSize: 32, + fontWeight: FontWeight.w300, + ), + header2: inlineCodeStyle.copyWith(fontSize: 22), + header3: inlineCodeStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), link: TextStyle( - color: themeData.accentColor, + color: themeData.colorScheme.secondary, decoration: TextDecoration.underline, ), placeHolder: DefaultTextBlockStyle( @@ -162,8 +278,8 @@ class DefaultStyles { const Tuple2(0, 0), const Tuple2(0, 0), null), - lists: DefaultTextBlockStyle( - baseStyle, baseSpacing, const Tuple2(0, 6), null), + lists: DefaultListBlockStyle( + baseStyle, baseSpacing, const Tuple2(0, 6), null, null), quote: DefaultTextBlockStyle( TextStyle(color: baseStyle.color!.withOpacity(0.6)), baseSpacing, @@ -205,8 +321,10 @@ class DefaultStyles { paragraph: other.paragraph ?? paragraph, bold: other.bold ?? bold, italic: other.italic ?? italic, + small: other.small ?? small, underline: other.underline ?? underline, strikeThrough: other.strikeThrough ?? strikeThrough, + inlineCode: other.inlineCode ?? inlineCode, link: other.link ?? link, color: other.color ?? color, placeHolder: other.placeHolder ?? placeHolder, diff --git a/lib/src/widgets/delegate.dart b/lib/src/widgets/delegate.dart index fa03c9a6..6b80cd68 100644 --- a/lib/src/widgets/delegate.dart +++ b/lib/src/widgets/delegate.dart @@ -1,11 +1,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import '../../flutter_quill.dart'; -import '../models/documents/nodes/leaf.dart'; -import 'editor.dart'; +import '../../flutter_quill.dart'; import 'text_selection.dart'; typedef EmbedBuilder = Widget Function( @@ -21,29 +18,94 @@ abstract class EditorTextSelectionGestureDetectorBuilderDelegate { bool getSelectionEnabled(); } +/// Builds a [EditorTextSelectionGestureDetector] to wrap an [EditableText]. +/// +/// The class implements sensible defaults for many user interactions +/// with an [EditableText] (see the documentation of the various gesture handler +/// methods, e.g. [onTapDown], [onForcePressStart], etc.). Subclasses of +/// [EditorTextSelectionGestureDetectorBuilder] can change the behavior +/// performed in responds to these gesture events by overriding +/// the corresponding handler methods of this class. +/// +/// The resulting [EditorTextSelectionGestureDetector] to wrap an [EditableText] +/// is obtained by calling [buildGestureDetector]. +/// +/// See also: +/// +/// * [TextField], which uses a subclass to implement the Material-specific +/// gesture logic of an [EditableText]. +/// * [CupertinoTextField], which uses a subclass to implement the +/// Cupertino-specific gesture logic of an [EditableText]. class EditorTextSelectionGestureDetectorBuilder { - EditorTextSelectionGestureDetectorBuilder(this.delegate); + /// Creates a [EditorTextSelectionGestureDetectorBuilder]. + /// + /// The [delegate] must not be null. + EditorTextSelectionGestureDetectorBuilder({required this.delegate}); + /// The delegate for this [EditorTextSelectionGestureDetectorBuilder]. + /// + /// The delegate provides the builder with information about what actions can + /// currently be performed on the textfield. Based on this, the builder adds + /// the correct gesture handlers to the gesture detector. + @protected final EditorTextSelectionGestureDetectorBuilderDelegate delegate; + + /// Whether to show the selection toolbar. + /// + /// It is based on the signal source when a [onTapDown] is called. This getter + /// will return true if current [onTapDown] event is triggered by a touch or + /// a stylus. bool shouldShowSelectionToolbar = true; + /// The [State] of the [EditableText] for which the builder will provide a + /// [EditorTextSelectionGestureDetector]. + @protected EditorState? getEditor() { return delegate.getEditableTextKey().currentState; } + /// The [RenderObject] of the [EditableText] for which the builder will + /// provide a [EditorTextSelectionGestureDetector]. + @protected RenderEditor? getRenderEditor() { return getEditor()!.getRenderEditor(); } + /// Handler for [EditorTextSelectionGestureDetector.onTapDown]. + /// + /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets + /// [shouldShowSelectionToolbar] to true if the tap was initiated by a finger + /// or stylus. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onTapDown], + /// which triggers this callback. + @protected void onTapDown(TapDownDetails details) { getRenderEditor()!.handleTapDown(details); - + // The selection overlay should only be shown when the user is interacting + // through a touch screen (via either a finger or a stylus). + // A mouse shouldn't trigger the selection overlay. + // For backwards-compatibility, we treat a null kind the same as touch. final kind = details.kind; shouldShowSelectionToolbar = kind == null || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus; } + /// Handler for [EditorTextSelectionGestureDetector.onForcePressStart]. + /// + /// By default, it selects the word at the position of the force press, + /// if selection is enabled. + /// + /// This callback is only applicable when force press is enabled. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onForcePressStart], + /// which triggers this callback. + @protected void onForcePressStart(ForcePressDetails details) { assert(delegate.getForcePressEnabled()); shouldShowSelectionToolbar = true; @@ -56,6 +118,18 @@ class EditorTextSelectionGestureDetectorBuilder { } } + /// Handler for [EditorTextSelectionGestureDetector.onForcePressEnd]. + /// + /// By default, it selects words in the range specified in [details] and shows + /// toolbar if it is necessary. + /// + /// This callback is only applicable when force press is enabled. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onForcePressEnd], + /// which triggers this callback. + @protected void onForcePressEnd(ForcePressDetails details) { assert(delegate.getForcePressEnabled()); getRenderEditor()!.selectWordsInRange( @@ -68,40 +142,97 @@ class EditorTextSelectionGestureDetectorBuilder { } } + /// Handler for [EditorTextSelectionGestureDetector.onSingleTapUp]. + /// + /// By default, it selects word edge if selection is enabled. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onSingleTapUp], which triggers + /// this callback. + @protected void onSingleTapUp(TapUpDetails details) { if (delegate.getSelectionEnabled()) { getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); } } - void onSingleTapCancel() {} + /// Handler for [EditorTextSelectionGestureDetector.onSingleTapCancel]. + /// + /// By default, it services as place holder to enable subclass override. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onSingleTapCancel], which triggers + /// this callback. + @protected + void onSingleTapCancel() { + /* Subclass should override this method if needed. */ + } + /// Handler for [EditorTextSelectionGestureDetector.onSingleLongTapStart]. + /// + /// By default, it selects text position specified in [details] if selection + /// is enabled. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onSingleLongTapStart], + /// which triggers this callback. + @protected void onSingleLongTapStart(LongPressStartDetails details) { if (delegate.getSelectionEnabled()) { getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, + from: details.globalPosition, + cause: SelectionChangedCause.longPress, ); } } + /// Handler for [EditorTextSelectionGestureDetector.onSingleLongTapMoveUpdate] + /// + /// By default, it updates the selection location specified in [details] if + /// selection is enabled. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onSingleLongTapMoveUpdate], which + /// triggers this callback. + @protected void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { if (delegate.getSelectionEnabled()) { getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, + from: details.globalPosition, + cause: SelectionChangedCause.longPress, ); } } + /// Handler for [EditorTextSelectionGestureDetector.onSingleLongTapEnd]. + /// + /// By default, it shows toolbar if necessary. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onSingleLongTapEnd], + /// which triggers this callback. + @protected void onSingleLongTapEnd(LongPressEndDetails details) { if (shouldShowSelectionToolbar) { getEditor()!.showToolbar(); } } + /// Handler for [EditorTextSelectionGestureDetector.onDoubleTapDown]. + /// + /// By default, it selects a word through [RenderEditable.selectWord] if + /// selectionEnabled and shows toolbar if necessary. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onDoubleTapDown], + /// which triggers this callback. + @protected void onDoubleTapDown(TapDownDetails details) { if (delegate.getSelectionEnabled()) { getRenderEditor()!.selectWord(SelectionChangedCause.tap); @@ -111,27 +242,56 @@ class EditorTextSelectionGestureDetectorBuilder { } } + /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionStart]. + /// + /// By default, it selects a text position specified in [details]. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onDragSelectionStart], + /// which triggers this callback. + @protected void onDragSelectionStart(DragStartDetails details) { - getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.drag, - ); + getRenderEditor()!.handleDragStart(details); } + /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionUpdate]. + /// + /// By default, it updates the selection location specified in the provided + /// details objects. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onDragSelectionUpdate], + /// which triggers this callback./lib/src/material/text_field.dart + @protected void onDragSelectionUpdate( DragStartDetails startDetails, DragUpdateDetails updateDetails) { - getRenderEditor()!.selectPositionAt( - startDetails.globalPosition, - updateDetails.globalPosition, - SelectionChangedCause.drag, - ); + getRenderEditor()!.extendSelection(updateDetails.globalPosition, + cause: SelectionChangedCause.drag); } - void onDragSelectionEnd(DragEndDetails details) {} + /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionEnd]. + /// + /// By default, it services as place holder to enable subclass override. + /// + /// See also: + /// + /// * [EditorTextSelectionGestureDetector.onDragSelectionEnd], + /// which triggers this callback. + @protected + void onDragSelectionEnd(DragEndDetails details) { + getRenderEditor()!.handleDragEnd(details); + } - Widget build(HitTestBehavior behavior, Widget child) { + /// Returns a [EditorTextSelectionGestureDetector] configured with + /// the handlers provided by this builder. + /// + /// The [child] or its subtree should contain [EditableText]. + Widget build( + {required HitTestBehavior behavior, required Widget child, Key? key}) { return EditorTextSelectionGestureDetector( + key: key, onTapDown: onTapDown, onForcePressStart: delegate.getForcePressEnabled() ? onForcePressStart : null, diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index d287e65c..b0b4dfd2 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -9,9 +9,7 @@ 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'; @@ -23,7 +21,9 @@ import 'controller.dart'; import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; +import 'float_cursor.dart'; import 'image.dart'; +import 'link.dart'; import 'raw_editor.dart'; import 'text_selection.dart'; import 'video_app.dart'; @@ -46,26 +46,27 @@ const linkPrefixes = [ 'http' ]; -abstract class EditorState extends State { +/// Base interface for the editor state which defines contract used by +/// various mixins. +abstract class EditorState extends State + implements TextSelectionDelegate { ScrollController get scrollController; - TextEditingValue getTextEditingValue(); - - void setTextEditingValue(TextEditingValue value); - RenderEditor? getRenderEditor(); EditorTextSelectionOverlay? getSelectionOverlay(); - bool showToolbar(); + /// Controls the floating cursor animation when it is released. + /// The floating cursor is animated to merge with the regular cursor. + AnimationController get floatingCursorResetController; - void hideToolbar(); + bool showToolbar(); void requestKeyboard(); } /// Base interface for editable render objects. -abstract class RenderAbstractEditor { +abstract class RenderAbstractEditor implements TextLayoutMetrics { TextSelection selectWordAtPosition(TextPosition position); TextSelection selectLineAtPosition(TextPosition position); @@ -87,9 +88,24 @@ abstract class RenderAbstractEditor { /// selection that contains some text but whose ends meet in the middle). TextPosition getPositionForOffset(Offset offset); + /// Returns the local coordinates of the endpoints of the given selection. + /// + /// If the selection is collapsed (and therefore occupies a single point), the + /// returned list is of length one. Otherwise, the selection is not collapsed + /// and the returned list is of length two. In this case, however, the two + /// points might actually be co-located (e.g., because of a bidirectional + /// selection that contains some text but whose ends meet in the middle). List getEndpointsForSelection( TextSelection textSelection); + /// Sets the screen position of the floating cursor and the text position + /// closest to the cursor. + /// `resetLerpValue` drives the size of the floating cursor. + /// See [EditorState.floatingCursorResetController]. + void setFloatingCursor(FloatingCursorDragState dragState, + Offset lastBoundedOffset, TextPosition lastTextPosition, + {double? resetLerpValue}); + /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] /// callback. @@ -115,8 +131,13 @@ abstract class RenderAbstractEditor { /// {@macro flutter.rendering.editable.select} void selectWordEdge(SelectionChangedCause cause); - /// Select text between the global positions [from] and [to]. - void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause); + /// + /// Returns the new selection. Note that the returned value may not be + /// yet reflected in the latest widget state. + /// + /// Returns null if no change occurred. + TextSelection? selectPositionAt( + {required Offset from, required SelectionChangedCause cause, Offset? to}); /// Select a word around the location of the last tap down. /// @@ -133,7 +154,7 @@ abstract class RenderAbstractEditor { /// If you have a [TextEditingController], it's generally easier to /// programmatically manipulate its `value` or `selection` directly. /// {@endtemplate} - void selectPosition(SelectionChangedCause cause); + void selectPosition({required SelectionChangedCause cause}); } String _standardizeImageUrl(String url) { @@ -145,7 +166,7 @@ String _standardizeImageUrl(String url) { bool _isMobile() => io.Platform.isAndroid || io.Platform.isIOS; -Widget _defaultEmbedBuilder( +Widget defaultEmbedBuilder( BuildContext context, leaf.Embed node, bool readOnly) { assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); switch (node.value.type) { @@ -165,33 +186,7 @@ Widget _defaultEmbedBuilder( final m = _attrs['mobileMargin'] == null ? 0.0 : double.parse(_attrs['mobileMargin']!); - var a = Alignment.center; - if (_attrs['mobileAlignment'] != null) { - final _index = [ - 'topLeft', - 'topCenter', - 'topRight', - 'centerLeft', - 'center', - 'centerRight', - 'bottomLeft', - 'bottomCenter', - 'bottomRight' - ].indexOf(_attrs['mobileAlignment']!); - if (_index >= 0) { - a = [ - Alignment.topLeft, - Alignment.topCenter, - Alignment.topRight, - Alignment.centerLeft, - Alignment.center, - Alignment.centerRight, - Alignment.bottomLeft, - Alignment.bottomCenter, - Alignment.bottomRight - ][_index]; - } - } + final a = getAlignment(_attrs['mobileAlignment']); return Padding( padding: EdgeInsets.all(m), child: imageUrl.startsWith('http') @@ -241,6 +236,7 @@ class QuillEditor extends StatefulWidget { this.scrollBottomInset = 0, this.minHeight, this.maxHeight, + this.maxContentWidth, this.customStyles, this.textCapitalization = TextCapitalization.sentences, this.keyboardAppearance = Brightness.light, @@ -251,45 +247,154 @@ class QuillEditor extends StatefulWidget { this.onSingleLongTapStart, this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, - this.embedBuilder = _defaultEmbedBuilder, + this.embedBuilder = defaultEmbedBuilder, + this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.customStyleBuilder, + this.floatingCursorDisabled = false, Key? key}); factory QuillEditor.basic({ required QuillController controller, required bool readOnly, + Brightness? keyboardAppearance, }) { return QuillEditor( - controller: controller, - scrollController: ScrollController(), - scrollable: true, - focusNode: FocusNode(), - autoFocus: true, - readOnly: readOnly, - expands: false, - padding: EdgeInsets.zero); + controller: controller, + scrollController: ScrollController(), + scrollable: true, + focusNode: FocusNode(), + autoFocus: true, + readOnly: readOnly, + expands: false, + padding: EdgeInsets.zero, + keyboardAppearance: keyboardAppearance ?? Brightness.light, + ); } + /// Controller object which establishes a link between a rich text document + /// and this editor. + /// + /// Must not be null. final QuillController controller; + + /// Controls whether this editor has keyboard focus. final FocusNode focusNode; + + /// The [ScrollController] to use when vertically scrolling the contents. final ScrollController scrollController; + + /// Whether this editor should create a scrollable container for its content. + /// + /// When set to `true` the editor's height can be controlled by [minHeight], + /// [maxHeight] and [expands] properties. + /// + /// When set to `false` the editor always expands to fit the entire content + /// of the document and should normally be placed as a child of another + /// scrollable widget, otherwise the content may be clipped. final bool scrollable; final double scrollBottomInset; + + /// Additional space around the content of this editor. final EdgeInsetsGeometry padding; + + /// Whether this editor should focus itself if nothing else is already + /// focused. + /// + /// If true, the keyboard will open as soon as this editor obtains focus. + /// Otherwise, the keyboard is only shown after the user taps the editor. + /// + /// Defaults to `false`. Cannot be `null`. final bool autoFocus; + + /// Whether to show cursor. + /// + /// The cursor refers to the blinking caret when the editor is focused. final bool? showCursor; final bool? paintCursorAboveText; + + /// Whether the text can be changed. + /// + /// When this is set to `true`, the text cannot be modified + /// by any shortcut or keyboard operation. The text is still selectable. + /// + /// Defaults to `false`. Must not be `null`. final bool readOnly; final String? placeholder; + + /// Whether to enable user interface affordances for changing the + /// text selection. + /// + /// For example, setting this to true will enable features such as + /// long-pressing the editor to select text and show the + /// cut/copy/paste menu, and tapping to move the text cursor. + /// + /// When this is false, the text selection cannot be adjusted by + /// the user, text cannot be copied, and the user cannot paste into + /// the text field from the clipboard. final bool enableInteractiveSelection; + + /// The minimum height to be occupied by this editor. + /// + /// This only has effect if [scrollable] is set to `true` and [expands] is + /// set to `false`. final double? minHeight; + + /// The maximum height to be occupied by this editor. + /// + /// This only has effect if [scrollable] is set to `true` and [expands] is + /// set to `false`. final double? maxHeight; + + /// The maximum width to be occupied by the content of this editor. + /// + /// If this is not null and and this editor's width is larger than this value + /// then the contents will be constrained to the provided maximum width and + /// horizontally centered. This is mostly useful on devices with wide screens. + final double? maxContentWidth; + final DefaultStyles? customStyles; + + /// Whether this editor's height will be sized to fill its parent. + /// + /// This only has effect if [scrollable] is set to `true`. + /// + /// If expands is set to true and wrapped in a parent widget like [Expanded] + /// or [SizedBox], the editor will expand to fill the parent. + /// + /// [maxHeight] and [minHeight] must both be `null` when this is set to + /// `true`. + /// + /// Defaults to `false`. final bool expands; + + /// Configures how the platform keyboard will select an uppercase or + /// lowercase keyboard. + /// + /// Only supports text keyboards, other keyboard types will ignore this + /// configuration. Capitalization is locale-aware. + /// + /// Defaults to [TextCapitalization.sentences]. Must not be `null`. final TextCapitalization textCapitalization; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// Defaults to [Brightness.light]. final Brightness keyboardAppearance; + + /// The [ScrollPhysics] to use when vertically scrolling the input. + /// + /// This only has effect if [scrollable] is set to `true`. + /// + /// If not specified, it will behave according to the current platform. + /// + /// See [Scrollable.physics]. final ScrollPhysics? scrollPhysics; + + /// Callback to invoke when user wants to launch a URL. final ValueChanged? onLaunchUrl; + // Returns whether gesture is handled final bool Function( TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; @@ -314,6 +419,23 @@ class QuillEditor extends StatefulWidget { final EmbedBuilder embedBuilder; final CustomStyleBuilder? customStyleBuilder; + /// Delegate function responsible for showing menu with link actions on + /// mobile platforms (iOS, Android). + /// + /// The menu is triggered in editing mode ([readOnly] is set to `false`) + /// when the user long-presses a link-styled text segment. + /// + /// FlutterQuill provides default implementation which can be overridden by + /// this field to customize the user experience. + /// + /// By default on iOS the menu is displayed with [showCupertinoModalPopup] + /// which constructs an instance of [CupertinoActionSheet]. For Android, + /// the menu is displayed with [showModalBottomSheet] and a list of + /// Material [ListTile]s. + final LinkActionPickerDelegate linkActionPickerDelegate; + + final bool floatingCursorDisabled; + @override _QuillEditorState createState() => _QuillEditorState(); } @@ -374,51 +496,56 @@ class _QuillEditorState extends State 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: widget.paintCursorAboveText ?? 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, - widget.customStyleBuilder, + final child = RawEditor( + key: _editorKey, + controller: widget.controller, + focusNode: widget.focusNode, + scrollController: widget.scrollController, + scrollable: widget.scrollable, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + readOnly: widget.readOnly, + placeholder: widget.placeholder, + onLaunchUrl: widget.onLaunchUrl, + toolbarOptions: ToolbarOptions( + copy: widget.enableInteractiveSelection, + cut: widget.enableInteractiveSelection, + paste: widget.enableInteractiveSelection, + selectAll: widget.enableInteractiveSelection, + ), + showSelectionHandles: theme.platform == TargetPlatform.iOS || + theme.platform == TargetPlatform.android, + showCursor: widget.showCursor, + cursorStyle: CursorStyle( + color: cursorColor, + backgroundColor: Colors.grey, + width: 2, + radius: cursorRadius, + offset: cursorOffset, + paintAboveText: widget.paintCursorAboveText ?? paintCursorAboveText, + opacityAnimates: cursorOpacityAnimates, ), + textCapitalization: widget.textCapitalization, + minHeight: widget.minHeight, + maxHeight: widget.maxHeight, + maxContentWidth: widget.maxContentWidth, + customStyles: widget.customStyles, + expands: widget.expands, + autoFocus: widget.autoFocus, + selectionColor: selectionColor, + selectionCtrls: textSelectionControls, + keyboardAppearance: widget.keyboardAppearance, + enableInteractiveSelection: widget.enableInteractiveSelection, + scrollPhysics: widget.scrollPhysics, + embedBuilder: widget.embedBuilder, + linkActionPickerDelegate: widget.linkActionPickerDelegate, + customStyleBuilder: widget.customStyleBuilder, + floatingCursorDisabled: widget.floatingCursorDisabled, + ); + + return _selectionGestureDetectorBuilder.build( + behavior: HitTestBehavior.translucent, + child: child, ); } @@ -444,7 +571,8 @@ class _QuillEditorState extends State class _QuillEditorSelectionGestureDetectorBuilder extends EditorTextSelectionGestureDetectorBuilder { - _QuillEditorSelectionGestureDetectorBuilder(this._state) : super(_state); + _QuillEditorSelectionGestureDetectorBuilder(this._state) + : super(delegate: _state); final _QuillEditorState _state; @@ -477,9 +605,8 @@ class _QuillEditorSelectionGestureDetectorBuilder case TargetPlatform.iOS: case TargetPlatform.macOS: getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, + from: details.globalPosition, + cause: SelectionChangedCause.longPress, ); break; case TargetPlatform.android: @@ -518,20 +645,6 @@ class _QuillEditorSelectionGestureDetectorBuilder 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') { @@ -555,10 +668,6 @@ class _QuillEditorSelectionGestureDetectorBuilder return false; } - Future _launchUrl(String url) async { - await launch(url); - } - @override void onTapDown(TapDownDetails details) { if (_state.widget.onTapDown != null) { @@ -573,6 +682,13 @@ class _QuillEditorSelectionGestureDetectorBuilder super.onTapDown(details); } + bool isShiftClick(PointerDeviceKind deviceKind) { + final pressed = RawKeyboard.instance.keysPressed; + return deviceKind == PointerDeviceKind.mouse && + (pressed.contains(LogicalKeyboardKey.shiftLeft) || + pressed.contains(LogicalKeyboardKey.shiftRight)); + } + @override void onSingleTapUp(TapUpDetails details) { if (_state.widget.onTapUp != null) { @@ -597,20 +713,38 @@ class _QuillEditorSelectionGestureDetectorBuilder case PointerDeviceKind.mouse: case PointerDeviceKind.stylus: case PointerDeviceKind.invertedStylus: - getRenderEditor()!.selectPosition(SelectionChangedCause.tap); + // Precise devices should place the cursor at a precise position. + // If `Shift` key is pressed then + // extend current selection instead. + if (isShiftClick(details.kind)) { + getRenderEditor()!.extendSelection(details.globalPosition, + cause: SelectionChangedCause.tap); + } else { + getRenderEditor()! + .selectPosition(cause: SelectionChangedCause.tap); + } + break; case PointerDeviceKind.touch: case PointerDeviceKind.unknown: - getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); - break; + // On macOS/iOS/iPadOS a touch tap places the cursor at the edge + // of the word. + try { + getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); + } finally { + break; + } } break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - getRenderEditor()!.selectPosition(SelectionChangedCause.tap); - break; + try { + getRenderEditor()!.selectPosition(cause: SelectionChangedCause.tap); + } finally { + break; + } } } _state._requestKeyboard(); @@ -633,9 +767,8 @@ class _QuillEditorSelectionGestureDetectorBuilder case TargetPlatform.iOS: case TargetPlatform.macOS: getRenderEditor()!.selectPositionAt( - details.globalPosition, - null, - SelectionChangedCause.longPress, + from: details.globalPosition, + cause: SelectionChangedCause.longPress, ); break; case TargetPlatform.android: @@ -666,24 +799,53 @@ class _QuillEditorSelectionGestureDetectorBuilder } } +/// Signature for the callback that reports when the user changes the selection +/// (including the cursor location). +/// +/// Used by [RenderEditor.onSelectionChanged]. typedef TextSelectionChangedHandler = void Function( TextSelection selection, SelectionChangedCause cause); +// The padding applied to text field. Used to determine the bounds when +// moving the floating cursor. +const EdgeInsets _kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5); + +// The additional size on the x and y axis with which to expand the prototype +// cursor to render the floating cursor in pixels. +const EdgeInsets _kFloatingCaretSizeIncrease = + EdgeInsets.symmetric(horizontal: 0.5, vertical: 1); + +/// Displays a document as a vertical list of document segments (lines +/// and blocks). +/// +/// Children of [RenderEditor] must be instances of [RenderEditableBox]. class RenderEditor extends RenderEditableContainerBox + with RelayoutWhenSystemFontsChangeMixin implements RenderAbstractEditor { - RenderEditor( + RenderEditor({ + required this.document, + required TextDirection textDirection, + required bool hasFocus, + required this.selection, + required LayerLink startHandleLayerLink, + required LayerLink endHandleLayerLink, + required EdgeInsetsGeometry padding, + required CursorCont cursorController, + required this.onSelectionChanged, + required double scrollBottomInset, + required this.floatingCursorDisabled, + ViewportOffset? offset, List? children, - TextDirection textDirection, - double scrollBottomInset, - EdgeInsetsGeometry padding, - this.document, - this.selection, - this._hasFocus, - this.onSelectionChanged, - this._startHandleLayerLink, - this._endHandleLayerLink, - EdgeInsets floatingCursorAddedMargin, - ) : super( + EdgeInsets floatingCursorAddedMargin = + const EdgeInsets.fromLTRB(4, 4, 4, 5), + double? maxContentWidth, + }) : _hasFocus = hasFocus, + _extendSelectionOrigin = selection, + _startHandleLayerLink = startHandleLayerLink, + _endHandleLayerLink = endHandleLayerLink, + _cursorController = cursorController, + _maxContentWidth = maxContentWidth, + super( children, document.root, textDirection, @@ -691,11 +853,16 @@ class RenderEditor extends RenderEditableContainerBox padding, ); + final CursorCont _cursorController; + final bool floatingCursorDisabled; + Document document; TextSelection selection; bool _hasFocus = false; LayerLink _startHandleLayerLink; LayerLink _endHandleLayerLink; + + /// Called when the selection changes. TextSelectionChangedHandler onSelectionChanged; final ValueNotifier _selectionStartInViewport = ValueNotifier(true); @@ -706,6 +873,41 @@ class RenderEditor extends RenderEditableContainerBox ValueListenable get selectionEndInViewport => _selectionEndInViewport; final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + void _updateSelectionExtentsVisibility(Offset effectiveOffset) { + final visibleRegion = Offset.zero & size; + final startPosition = + TextPosition(offset: selection.start, affinity: selection.affinity); + final startOffset = _getOffsetForCaret(startPosition); + // TODO(justinmc): https://github.com/flutter/flutter/issues/31495 + // Check if the selection is visible with an approximation because a + // difference between rounded and unrounded values causes the caret to be + // reported as having a slightly (< 0.5) negative y offset. This rounding + // happens in paragraph.cc's layout and TextPainer's + // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and + // this can be changed to be a strict check instead of an approximation. + const visibleRegionSlop = 0.5; + _selectionStartInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(startOffset + effectiveOffset); + + final endPosition = + TextPosition(offset: selection.end, affinity: selection.affinity); + final endOffset = _getOffsetForCaret(endPosition); + _selectionEndInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(endOffset + effectiveOffset); + } + + // returns offset relative to this at which the caret will be painted + // given a global TextPosition + Offset _getOffsetForCaret(TextPosition position) { + final child = childAtPosition(position); + final childPosition = child.globalToLocalPosition(position); + final boxParentData = child.parentData as BoxParentData; + final localOffsetForCaret = child.getOffsetForCaret(childPosition); + return boxParentData.offset + localOffsetForCaret; + } + void setDocument(Document doc) { if (document == doc) { return; @@ -722,14 +924,37 @@ class RenderEditor extends RenderEditableContainerBox markNeedsSemanticsUpdate(); } + Offset get _paintOffset => Offset(0, -(offset?.pixels ?? 0.0)); + + ViewportOffset? get offset => _offset; + ViewportOffset? _offset; + + set offset(ViewportOffset? value) { + if (_offset == value) return; + if (attached) _offset?.removeListener(markNeedsPaint); + _offset = value; + if (attached) _offset?.addListener(markNeedsPaint); + markNeedsLayout(); + } + void setSelection(TextSelection t) { if (selection == t) { return; } selection = t; markNeedsPaint(); + + if (!_shiftPressed && !_isDragging) { + // Only update extend selection origin if Shift key is not pressed and + // user is not dragging selection. + _extendSelectionOrigin = selection; + } } + bool get _shiftPressed => + RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) || + RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight); + void setStartHandleLayerLink(LayerLink value) { if (_startHandleLayerLink == value) { return; @@ -754,6 +979,13 @@ class RenderEditor extends RenderEditableContainerBox markNeedsPaint(); } + double? _maxContentWidth; + set maxContentWidth(double? value) { + if (_maxContentWidth == value) return; + _maxContentWidth = value; + markNeedsLayout(); + } + @override List getEndpointsForSelection( TextSelection textSelection) { @@ -813,11 +1045,35 @@ class RenderEditor extends RenderEditableContainerBox Offset? _lastTapDownPosition; + // Used on Desktop (mouse and keyboard enabled platforms) as base offset + // for extending selection, either with combination of `Shift` + Click or + // by dragging + TextSelection? _extendSelectionOrigin; + @override void handleTapDown(TapDownDetails details) { _lastTapDownPosition = details.globalPosition; } + bool _isDragging = false; + + void handleDragStart(DragStartDetails details) { + _isDragging = true; + + final newSelection = selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + + if (newSelection == null) return; + // Make sure to remember the origin for extend selection. + _extendSelectionOrigin = newSelection; + } + + void handleDragEnd(DragEndDetails details) { + _isDragging = false; + } + @override void selectWordsInRange( Offset from, @@ -854,6 +1110,34 @@ class RenderEditor extends RenderEditableContainerBox onSelectionChanged(nextSelection, cause); } + /// Extends current selection to the position closest to specified offset. + void extendSelection(Offset to, {required SelectionChangedCause cause}) { + /// The below logic does not exactly match the native version because + /// we do not allow swapping of base and extent positions. + assert(_extendSelectionOrigin != null); + final position = getPositionForOffset(to); + + if (position.offset < _extendSelectionOrigin!.baseOffset) { + _handleSelectionChange( + TextSelection( + baseOffset: position.offset, + extentOffset: _extendSelectionOrigin!.extentOffset, + affinity: selection.affinity, + ), + cause, + ); + } else if (position.offset > _extendSelectionOrigin!.extentOffset) { + _handleSelectionChange( + TextSelection( + baseOffset: _extendSelectionOrigin!.baseOffset, + extentOffset: position.offset, + affinity: selection.affinity, + ), + cause, + ); + } + } + @override void selectWordEdge(SelectionChangedCause cause) { assert(_lastTapDownPosition != null); @@ -884,11 +1168,11 @@ class RenderEditor extends RenderEditableContainerBox } @override - void selectPositionAt( - Offset from, + TextSelection? selectPositionAt({ + required Offset from, + required SelectionChangedCause cause, Offset? to, - SelectionChangedCause cause, - ) { + }) { final fromPosition = getPositionForOffset(from); final toPosition = to == null ? null : getPositionForOffset(to); @@ -904,7 +1188,10 @@ class RenderEditor extends RenderEditableContainerBox extentOffset: extentOffset, affinity: fromPosition.affinity, ); + + // Call [onSelectionChanged] only when the selection actually changed. _handleSelectionChange(newSelection, cause); + return newSelection; } @override @@ -913,21 +1200,14 @@ class RenderEditor extends RenderEditableContainerBox } @override - void selectPosition(SelectionChangedCause cause) { - selectPositionAt(_lastTapDownPosition!, null, cause); + void selectPosition({required SelectionChangedCause cause}) { + selectPositionAt(from: _lastTapDownPosition!, cause: 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, - ); + final word = getWordBoundary(position); + // When long-pressing past the end of the text, we want a collapsed cursor. if (position.offset >= word.end) { return TextSelection.fromPosition(position); } @@ -936,26 +1216,85 @@ class RenderEditor extends RenderEditableContainerBox @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, - ); + final line = getLineAtOffset(position); + // When long-pressing past the end of the text, we want a collapsed cursor. if (position.offset >= line.end) { return TextSelection.fromPosition(position); } return TextSelection(baseOffset: line.start, extentOffset: line.end); } + @override + void performLayout() { + assert(() { + if (!constraints.hasBoundedHeight) return true; + throw FlutterError.fromParts([ + ErrorSummary('RenderEditableContainerBox must have ' + 'unlimited space along its main axis.'), + ErrorDescription('RenderEditableContainerBox does not clip or' + ' resize its children, so it must be ' + 'placed in a parent that does not constrain the main ' + 'axis.'), + ErrorHint( + 'You probably want to put the RenderEditableContainerBox inside a ' + 'RenderViewport with a matching main axis.') + ]); + }()); + assert(() { + if (constraints.hasBoundedWidth) return true; + throw FlutterError.fromParts([ + ErrorSummary('RenderEditableContainerBox must have a bounded' + ' constraint for its cross axis.'), + ErrorDescription('RenderEditableContainerBox forces its children to ' + "expand to fit the RenderEditableContainerBox's container, " + 'so it must be placed in a parent that constrains the cross ' + 'axis to a finite dimension.'), + ]); + }()); + + resolvePadding(); + assert(resolvedPadding != null); + + var mainAxisExtent = resolvedPadding!.top; + var child = firstChild; + final innerConstraints = BoxConstraints.tightFor( + width: math.min( + _maxContentWidth ?? double.infinity, constraints.maxWidth)) + .deflate(resolvedPadding!); + final leftOffset = _maxContentWidth == null + ? 0.0 + : math.max((constraints.maxWidth - _maxContentWidth!) / 2, 0); + while (child != null) { + child.layout(innerConstraints, parentUsesSize: true); + final childParentData = child.parentData as EditableContainerParentData + ..offset = Offset(resolvedPadding!.left + leftOffset, 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); + } + @override void paint(PaintingContext context, Offset offset) { + if (_hasFocus && + _cursorController.show.value && + !_cursorController.style.paintAboveText) { + _paintFloatingCursor(context, offset); + } defaultPaint(context, offset); + _updateSelectionExtentsVisibility(offset + _paintOffset); _paintHandleLayers(context, getEndpointsForSelection(selection)); + + if (_hasFocus && + _cursorController.show.value && + _cursorController.style.paintAboveText) { + _paintFloatingCursor(context, offset); + } } @override @@ -1067,6 +1406,249 @@ class RenderEditor extends RenderEditableContainerBox final boxParentData = targetChild.parentData as BoxParentData; return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); } + + // Start floating cursor + + FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter( + floatingCursorRect: _floatingCursorRect, + style: _cursorController.style, + ); + + bool _floatingCursorOn = false; + Rect? _floatingCursorRect; + + TextPosition get floatingCursorTextPosition => _floatingCursorTextPosition; + late TextPosition _floatingCursorTextPosition; + + // The relative origin in relation to the distance the user has theoretically + // dragged the floating cursor offscreen. + // This value is used to account for the difference + // in the rendering position and the raw offset value. + Offset _relativeOrigin = Offset.zero; + Offset? _previousOffset; + bool _resetOriginOnLeft = false; + bool _resetOriginOnRight = false; + bool _resetOriginOnTop = false; + bool _resetOriginOnBottom = false; + + /// Returns the position within the editor closest to the raw cursor offset. + Offset calculateBoundedFloatingCursorOffset( + Offset rawCursorOffset, double preferredLineHeight) { + var deltaPosition = Offset.zero; + final topBound = _kFloatingCursorAddedMargin.top; + final bottomBound = + size.height - preferredLineHeight + _kFloatingCursorAddedMargin.bottom; + final leftBound = _kFloatingCursorAddedMargin.left; + final rightBound = size.width - _kFloatingCursorAddedMargin.right; + + if (_previousOffset != null) { + deltaPosition = rawCursorOffset - _previousOffset!; + } + + // If the raw cursor offset has gone off an edge, + // we want to reset the relative origin of + // the dragging when the user drags back into the field. + if (_resetOriginOnLeft && deltaPosition.dx > 0) { + _relativeOrigin = + Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); + _resetOriginOnLeft = false; + } else if (_resetOriginOnRight && deltaPosition.dx < 0) { + _relativeOrigin = + Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); + _resetOriginOnRight = false; + } + if (_resetOriginOnTop && deltaPosition.dy > 0) { + _relativeOrigin = + Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); + _resetOriginOnTop = false; + } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { + _relativeOrigin = + Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); + _resetOriginOnBottom = false; + } + + final currentX = rawCursorOffset.dx - _relativeOrigin.dx; + final currentY = rawCursorOffset.dy - _relativeOrigin.dy; + final double adjustedX = + math.min(math.max(currentX, leftBound), rightBound); + final double adjustedY = + math.min(math.max(currentY, topBound), bottomBound); + final adjustedOffset = Offset(adjustedX, adjustedY); + + if (currentX < leftBound && deltaPosition.dx < 0) { + _resetOriginOnLeft = true; + } else if (currentX > rightBound && deltaPosition.dx > 0) { + _resetOriginOnRight = true; + } + if (currentY < topBound && deltaPosition.dy < 0) { + _resetOriginOnTop = true; + } else if (currentY > bottomBound && deltaPosition.dy > 0) { + _resetOriginOnBottom = true; + } + + _previousOffset = rawCursorOffset; + + return adjustedOffset; + } + + @override + void setFloatingCursor(FloatingCursorDragState dragState, + Offset boundedOffset, TextPosition textPosition, + {double? resetLerpValue}) { + if (floatingCursorDisabled) return; + + if (dragState == FloatingCursorDragState.Start) { + _relativeOrigin = Offset.zero; + _previousOffset = null; + _resetOriginOnBottom = false; + _resetOriginOnTop = false; + _resetOriginOnRight = false; + _resetOriginOnBottom = false; + } + _floatingCursorOn = dragState != FloatingCursorDragState.End; + if (_floatingCursorOn) { + _floatingCursorTextPosition = textPosition; + final sizeAdjustment = resetLerpValue != null + ? EdgeInsets.lerp( + _kFloatingCaretSizeIncrease, EdgeInsets.zero, resetLerpValue)! + : _kFloatingCaretSizeIncrease; + final child = childAtPosition(textPosition); + final caretPrototype = + child.getCaretPrototype(child.globalToLocalPosition(textPosition)); + _floatingCursorRect = + sizeAdjustment.inflateRect(caretPrototype).shift(boundedOffset); + _cursorController + .setFloatingCursorTextPosition(_floatingCursorTextPosition); + } else { + _floatingCursorRect = null; + _cursorController.setFloatingCursorTextPosition(null); + } + } + + void _paintFloatingCursor(PaintingContext context, Offset offset) { + _floatingCursorPainter.paint(context.canvas); + } + + // End floating cursor + + // Start TextLayoutMetrics implementation + + /// Return a [TextSelection] containing the line of the given [TextPosition]. + @override + TextSelection getLineAtOffset(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, + ); + return TextSelection(baseOffset: line.start, extentOffset: line.end); + } + + @override + TextRange getWordBoundary(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); + return TextRange( + start: localWord.start + nodeOffset, + end: localWord.end + nodeOffset, + ); + } + + /// Returns the TextPosition above the given offset into the text. + /// + /// If the offset is already on the first line, the offset of the first + /// character will be returned. + @override + TextPosition getTextPositionAbove(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.getContainer().documentOffset); + + var newPosition = child.getPositionAbove(localPosition); + + if (newPosition == null) { + // There was no text above in the current child, check the direct + // sibling. + final sibling = childBefore(child); + if (sibling == null) { + // reached beginning of the document, move to the + // first character + newPosition = const TextPosition(offset: 0); + } else { + final caretOffset = child.getOffsetForCaret(localPosition); + final testPosition = + TextPosition(offset: sibling.getContainer().length - 1); + final testOffset = sibling.getOffsetForCaret(testPosition); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + final siblingPosition = sibling.getPositionForOffset(finalOffset); + newPosition = TextPosition( + offset: + sibling.getContainer().documentOffset + siblingPosition.offset); + } + } else { + newPosition = TextPosition( + offset: child.getContainer().documentOffset + newPosition.offset); + } + return newPosition; + } + + /// Returns the TextPosition below the given offset into the text. + /// + /// If the offset is already on the last line, the offset of the last + /// character will be returned. + @override + TextPosition getTextPositionBelow(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.getContainer().documentOffset); + + var newPosition = child.getPositionBelow(localPosition); + + if (newPosition == null) { + // There was no text above in the current child, check the direct + // sibling. + final sibling = childAfter(child); + if (sibling == null) { + // reached beginning of the document, move to the + // last character + newPosition = TextPosition(offset: document.length - 1); + } else { + final caretOffset = child.getOffsetForCaret(localPosition); + const testPosition = TextPosition(offset: 0); + final testOffset = sibling.getOffsetForCaret(testPosition); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + final siblingPosition = sibling.getPositionForOffset(finalOffset); + newPosition = TextPosition( + offset: + sibling.getContainer().documentOffset + siblingPosition.offset); + } + } else { + newPosition = TextPosition( + offset: child.getContainer().documentOffset + newPosition.offset); + } + return newPosition; + } + + // End TextLayoutMetrics implementation + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + markNeedsLayout(); + } + + void debugAssertLayoutUpToDate() { + // no-op? + // this assert was added by Flutter TextEditingActionTarge + // so we have to comply here. + } } class EditableContainerParentData @@ -1119,7 +1701,7 @@ class RenderEditableContainerBox extends RenderBox EdgeInsets? get resolvedPadding => _resolvedPadding; - void _resolvePadding() { + void resolvePadding() { if (_resolvedPadding != null) { return; } @@ -1139,7 +1721,11 @@ class RenderEditableContainerBox extends RenderBox if (targetChild.getContainer() == targetNode) { break; } - targetChild = childAfter(targetChild); + final newChild = childAfter(targetChild); + if (newChild == null) { + break; + } + targetChild = newChild; } if (targetChild == null) { throw 'targetChild should not be null'; @@ -1154,7 +1740,7 @@ class RenderEditableContainerBox extends RenderBox RenderEditableBox? childAtOffset(Offset offset) { assert(firstChild != null); - _resolvePadding(); + resolvePadding(); if (offset.dy <= _resolvedPadding!.top) { return firstChild; @@ -1188,7 +1774,7 @@ class RenderEditableContainerBox extends RenderBox @override void performLayout() { assert(constraints.hasBoundedWidth); - _resolvePadding(); + resolvePadding(); assert(_resolvedPadding != null); var mainAxisExtent = _resolvedPadding!.top; @@ -1234,7 +1820,7 @@ class RenderEditableContainerBox extends RenderBox @override double computeMinIntrinsicWidth(double height) { - _resolvePadding(); + resolvePadding(); return _getIntrinsicCrossAxis((child) { final childHeight = math.max( 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); @@ -1246,7 +1832,7 @@ class RenderEditableContainerBox extends RenderBox @override double computeMaxIntrinsicWidth(double height) { - _resolvePadding(); + resolvePadding(); return _getIntrinsicCrossAxis((child) { final childHeight = math.max( 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); @@ -1258,7 +1844,7 @@ class RenderEditableContainerBox extends RenderBox @override double computeMinIntrinsicHeight(double width) { - _resolvePadding(); + resolvePadding(); return _getIntrinsicMainAxis((child) { final childWidth = math.max( 0, width - _resolvedPadding!.left + _resolvedPadding!.right); @@ -1270,7 +1856,7 @@ class RenderEditableContainerBox extends RenderBox @override double computeMaxIntrinsicHeight(double width) { - _resolvePadding(); + resolvePadding(); return _getIntrinsicMainAxis((child) { final childWidth = math.max( 0, width - _resolvedPadding!.left + _resolvedPadding!.right); @@ -1282,7 +1868,7 @@ class RenderEditableContainerBox extends RenderBox @override double computeDistanceToActualBaseline(TextBaseline baseline) { - _resolvePadding(); + resolvePadding(); return defaultComputeDistanceToFirstActualBaseline(baseline)! + _resolvedPadding!.top; } diff --git a/lib/src/widgets/float_cursor.dart b/lib/src/widgets/float_cursor.dart new file mode 100644 index 00000000..b67efc29 --- /dev/null +++ b/lib/src/widgets/float_cursor.dart @@ -0,0 +1,31 @@ +// The corner radius of the floating cursor in pixels. +import 'dart:ui'; + +import '../../widgets/cursor.dart'; + +const Radius _kFloatingCaretRadius = Radius.circular(1); + +/// Floating painter responsible for painting the floating cursor when +/// floating mode is activated +class FloatingCursorPainter { + FloatingCursorPainter({ + required this.floatingCursorRect, + required this.style, + }); + + CursorStyle style; + + Rect? floatingCursorRect; + + final Paint floatingCursorPaint = Paint(); + + void paint(Canvas canvas) { + final floatingCursorRect = this.floatingCursorRect; + final floatingCursorColor = style.color.withOpacity(0.75); + if (floatingCursorRect == null) return; + canvas.drawRRect( + RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius), + floatingCursorPaint..color = floatingCursorColor, + ); + } +} diff --git a/lib/src/widgets/image.dart b/lib/src/widgets/image.dart index b9df48ce..3d09bd74 100644 --- a/lib/src/widgets/image.dart +++ b/lib/src/widgets/image.dart @@ -1,6 +1,4 @@ -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 { @@ -17,13 +15,52 @@ class ImageTapWrapper extends StatelessWidget { constraints: BoxConstraints.expand( height: MediaQuery.of(context).size.height, ), - child: GestureDetector( - onTapDown: (_) { - Navigator.pop(context); - }, - child: PhotoView( - imageProvider: imageProvider, - ), + child: Stack( + children: [ + PhotoView( + imageProvider: imageProvider, + loadingBuilder: (context, event) { + return Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ), + Positioned( + right: 10, + top: MediaQuery.of(context).padding.top + 10.0, + child: InkWell( + onTap: () { + Navigator.pop(context); + }, + child: Stack( + children: [ + Opacity( + opacity: 0.2, + child: Container( + height: 30, + width: 30, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black87, + ), + ), + ), + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: + Icon(Icons.close, color: Colors.grey[400], size: 28), + ) + ], + ), + ), + ), + ], ), ), ); diff --git a/lib/src/widgets/keyboard_listener.dart b/lib/src/widgets/keyboard_listener.dart index ceabf931..47cb9555 100644 --- a/lib/src/widgets/keyboard_listener.dart +++ b/lib/src/widgets/keyboard_listener.dart @@ -1,106 +1,89 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.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, - }; - - KeyEventResult handleRawKeyEvent(RawKeyEvent event) { - if (kIsWeb) { - // On web platform, we ignore the key because it's already processed. - return KeyEventResult.ignored; - } - if (event is! RawKeyDownEvent) { - return KeyEventResult.ignored; - } +class QuillPressedKeys extends ChangeNotifier { + static QuillPressedKeys of(BuildContext context) { + final widget = + context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>(); + return widget!.pressedKeys; + } - 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 KeyEventResult.ignored; - } + bool _metaPressed = false; + bool _controlPressed = 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); + /// Whether meta key is currently pressed. + bool get metaPressed => _metaPressed; + + /// Whether control key is currently pressed. + bool get controlPressed => _controlPressed; + + void _updatePressedKeys(Set pressedKeys) { + final meta = pressedKeys.contains(LogicalKeyboardKey.metaLeft) || + pressedKeys.contains(LogicalKeyboardKey.metaRight); + final control = pressedKeys.contains(LogicalKeyboardKey.controlLeft) || + pressedKeys.contains(LogicalKeyboardKey.controlRight); + if (_metaPressed != meta || _controlPressed != control) { + _metaPressed = meta; + _controlPressed = control; + notifyListeners(); } - return KeyEventResult.ignored; + } +} + +class QuillKeyboardListener extends StatefulWidget { + const QuillKeyboardListener({required this.child, Key? key}) + : super(key: key); + + final Widget child; + + @override + QuillKeyboardListenerState createState() => QuillKeyboardListenerState(); +} + +class QuillKeyboardListenerState extends State { + final QuillPressedKeys _pressedKeys = QuillPressedKeys(); + + bool _keyEvent(KeyEvent event) { + _pressedKeys + ._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed); + return false; + } + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_keyEvent); + _pressedKeys + ._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_keyEvent); + _pressedKeys.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _QuillPressedKeysAccess( + pressedKeys: _pressedKeys, + child: widget.child, + ); + } +} + +class _QuillPressedKeysAccess extends InheritedWidget { + const _QuillPressedKeysAccess({ + required this.pressedKeys, + required Widget child, + Key? key, + }) : super(key: key, child: child); + + final QuillPressedKeys pressedKeys; + + @override + bool updateShouldNotify(covariant _QuillPressedKeysAccess oldWidget) { + return oldWidget.pressedKeys != pressedKeys; } } diff --git a/lib/src/widgets/link.dart b/lib/src/widgets/link.dart new file mode 100644 index 00000000..87378ca5 --- /dev/null +++ b/lib/src/widgets/link.dart @@ -0,0 +1,170 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../models/documents/nodes/node.dart'; + +/// List of possible actions returned from [LinkActionPickerDelegate]. +enum LinkMenuAction { + /// Launch the link + launch, + + /// Copy to clipboard + copy, + + /// Remove link style attribute + remove, + + /// No-op + none, +} + +/// Used internally by widget layer. +typedef LinkActionPicker = Future Function(Node linkNode); + +typedef LinkActionPickerDelegate = Future Function( + BuildContext context, String link); + +Future defaultLinkActionPickerDelegate( + BuildContext context, String link) async { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return _showCupertinoLinkMenu(context, link); + case TargetPlatform.android: + return _showMaterialMenu(context, link); + default: + assert( + false, + 'defaultShowLinkActionsMenu not supposed to ' + 'be invoked for $defaultTargetPlatform'); + return LinkMenuAction.none; + } +} + +Future _showCupertinoLinkMenu( + BuildContext context, String link) async { + final result = await showCupertinoModalPopup( + context: context, + builder: (ctx) { + return CupertinoActionSheet( + title: Text(link), + actions: [ + _CupertinoAction( + title: 'Open', + icon: Icons.language_sharp, + onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch), + ), + _CupertinoAction( + title: 'Copy', + icon: Icons.copy_sharp, + onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy), + ), + _CupertinoAction( + title: 'Remove', + icon: Icons.link_off_sharp, + onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove), + ), + ], + ); + }, + ); + return result ?? LinkMenuAction.none; +} + +class _CupertinoAction extends StatelessWidget { + const _CupertinoAction({ + required this.title, + required this.icon, + required this.onPressed, + Key? key, + }) : super(key: key); + + final String title; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return CupertinoActionSheetAction( + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + textAlign: TextAlign.start, + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ), + Icon( + icon, + size: theme.iconTheme.size, + color: theme.colorScheme.onSurface.withOpacity(0.75), + ) + ], + ), + ), + ); + } +} + +Future _showMaterialMenu( + BuildContext context, String link) async { + final result = await showModalBottomSheet( + context: context, + builder: (ctx) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _MaterialAction( + title: 'Open', + icon: Icons.language_sharp, + onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch), + ), + _MaterialAction( + title: 'Copy', + icon: Icons.copy_sharp, + onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy), + ), + _MaterialAction( + title: 'Remove', + icon: Icons.link_off_sharp, + onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove), + ), + ], + ); + }, + ); + + return result ?? LinkMenuAction.none; +} + +class _MaterialAction extends StatelessWidget { + const _MaterialAction({ + required this.title, + required this.icon, + required this.onPressed, + Key? key, + }) : super(key: key); + + final String title; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListTile( + leading: Icon( + icon, + size: theme.iconTheme.size, + color: theme.colorScheme.onSurface.withOpacity(0.75), + ), + title: Text(title), + onTap: onPressed, + ); + } +} diff --git a/lib/src/widgets/link_dialog.dart b/lib/src/widgets/link_dialog.dart index 3f9d7feb..e6173402 100644 --- a/lib/src/widgets/link_dialog.dart +++ b/lib/src/widgets/link_dialog.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import '../models/themes/quill_dialog_theme.dart'; +import '../translations/toolbar.i18n.dart'; + class LinkDialog extends StatefulWidget { - const LinkDialog({Key? key}) : super(key: key); + const LinkDialog({this.dialogTheme, Key? key}) : super(key: key); + + final QuillDialogTheme? dialogTheme; @override LinkDialogState createState() => LinkDialogState(); @@ -13,15 +18,23 @@ class LinkDialogState extends State { @override Widget build(BuildContext context) { return AlertDialog( + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, content: TextField( - decoration: const InputDecoration(labelText: 'Paste a link'), + style: widget.dialogTheme?.inputTextStyle, + decoration: InputDecoration( + labelText: 'Paste a link'.i18n, + labelStyle: widget.dialogTheme?.labelTextStyle, + floatingLabelStyle: widget.dialogTheme?.labelTextStyle), autofocus: true, onChanged: _linkChanged, ), actions: [ TextButton( onPressed: _link.isNotEmpty ? _applyLink : null, - child: const Text('Ok'), + child: Text( + 'Ok'.i18n, + style: widget.dialogTheme?.labelTextStyle, + ), ), ], ); diff --git a/lib/src/widgets/proxy.dart b/lib/src/widgets/proxy.dart index a1cb8c6a..b9f683e1 100644 --- a/lib/src/widgets/proxy.dart +++ b/lib/src/widgets/proxy.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -127,17 +129,19 @@ class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { } class RichTextProxy extends SingleChildRenderObjectWidget { + /// Child argument should be an instance of RichText widget. const RichTextProxy( - RichText child, - this.textStyle, - this.textAlign, - this.textDirection, - this.textScaleFactor, - this.locale, - this.strutStyle, - this.textWidthBasis, - this.textHeightBehavior, - ) : super(child: child); + {required RichText child, + required this.textStyle, + required this.textAlign, + required this.textDirection, + required this.locale, + required this.strutStyle, + this.textScaleFactor = 1.0, + this.textWidthBasis = TextWidthBasis.parent, + this.textHeightBehavior, + Key? key}) + : super(key: key, child: child); final TextStyle textStyle; final TextAlign textAlign; @@ -291,8 +295,8 @@ class RenderParagraphProxy extends RenderProxyBox child!.getWordBoundary(position); @override - List getBoxesForSelection(TextSelection selection) => - child!.getBoxesForSelection(selection); + List getBoxesForSelection(TextSelection selection) => child! + .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.strut); @override void performLayout() { diff --git a/lib/src/widgets/quill_single_child_scroll_view.dart b/lib/src/widgets/quill_single_child_scroll_view.dart new file mode 100644 index 00000000..40971b06 --- /dev/null +++ b/lib/src/widgets/quill_single_child_scroll_view.dart @@ -0,0 +1,354 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Very similar to [SingleChildView] but with a [ViewportBuilder] argument +/// instead of a [Widget] +/// +/// Useful when child needs [ViewportOffset] (e.g. [RenderEditor]) +/// see: [SingleChildScrollView] +class QuillSingleChildScrollView extends StatelessWidget { + /// Creates a box in which a single widget can be scrolled. + const QuillSingleChildScrollView({ + required this.controller, + required this.viewportBuilder, + Key? key, + this.physics, + this.restorationId, + }) : super(key: key); + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + final ScrollController controller; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + final ViewportBuilder viewportBuilder; + + AxisDirection _getDirection(BuildContext context) { + return getAxisDirectionFromAxisReverseAndDirectionality( + context, Axis.vertical, false); + } + + @override + Widget build(BuildContext context) { + final axisDirection = _getDirection(context); + final scrollController = controller; + final scrollable = Scrollable( + axisDirection: axisDirection, + controller: scrollController, + physics: physics, + restorationId: restorationId, + viewportBuilder: (context, offset) { + return _SingleChildViewport( + offset: offset, + child: viewportBuilder(context, offset), + ); + }, + ); + return scrollable; + } +} + +class _SingleChildViewport extends SingleChildRenderObjectWidget { + const _SingleChildViewport({ + required this.offset, + Key? key, + Widget? child, + }) : super(key: key, child: child); + + final ViewportOffset offset; + + @override + _RenderSingleChildViewport createRenderObject(BuildContext context) { + return _RenderSingleChildViewport( + offset: offset, + ); + } + + @override + void updateRenderObject( + BuildContext context, _RenderSingleChildViewport renderObject) { + // Order dependency: The offset setter reads the axis direction. + renderObject.offset = offset; + } +} + +class _RenderSingleChildViewport extends RenderBox + with RenderObjectWithChildMixin + implements RenderAbstractViewport { + _RenderSingleChildViewport({ + required ViewportOffset offset, + double cacheExtent = RenderAbstractViewport.defaultCacheExtent, + RenderBox? child, + }) : _offset = offset, + _cacheExtent = cacheExtent { + this.child = child; + } + + ViewportOffset get offset => _offset; + ViewportOffset _offset; + + set offset(ViewportOffset value) { + if (value == _offset) return; + if (attached) _offset.removeListener(_hasScrolled); + _offset = value; + if (attached) _offset.addListener(_hasScrolled); + markNeedsLayout(); + } + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + double get cacheExtent => _cacheExtent; + double _cacheExtent; + + set cacheExtent(double value) { + if (value == _cacheExtent) return; + _cacheExtent = value; + markNeedsLayout(); + } + + void _hasScrolled() { + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + @override + void setupParentData(RenderObject child) { + // We don't actually use the offset argument in BoxParentData, so let's + // avoid allocating it at all. + if (child.parentData is! ParentData) child.parentData = ParentData(); + } + + @override + bool get isRepaintBoundary => true; + + double get _viewportExtent { + assert(hasSize); + return size.height; + } + + double get _minScrollExtent { + assert(hasSize); + return 0; + } + + double get _maxScrollExtent { + assert(hasSize); + if (child == null) return 0; + return math.max(0, child!.size.height - size.height); + } + + BoxConstraints _getInnerConstraints(BoxConstraints constraints) { + return constraints.widthConstraints(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) return child!.getMinIntrinsicWidth(height); + return 0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) return child!.getMaxIntrinsicWidth(height); + return 0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) return child!.getMinIntrinsicHeight(width); + return 0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) return child!.getMaxIntrinsicHeight(width); + return 0; + } + + // We don't override computeDistanceToActualBaseline(), because we + // want the default behavior (returning null). Otherwise, as you + // scroll, it would shift in its parent if the parent was baseline-aligned, + // which makes no sense. + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child == null) { + return constraints.smallest; + } + final childSize = child!.getDryLayout(_getInnerConstraints(constraints)); + return constraints.constrain(childSize); + } + + @override + void performLayout() { + final constraints = this.constraints; + if (child == null) { + size = constraints.smallest; + } else { + child!.layout(_getInnerConstraints(constraints), parentUsesSize: true); + size = constraints.constrain(child!.size); + } + + offset + ..applyViewportDimension(_viewportExtent) + ..applyContentDimensions(_minScrollExtent, _maxScrollExtent); + } + + Offset get _paintOffset => _paintOffsetForPosition(offset.pixels); + + Offset _paintOffsetForPosition(double position) { + return Offset(0, -position); + } + + bool _shouldClipAtPaintOffset(Offset paintOffset) { + assert(child != null); + return paintOffset.dx < 0 || + paintOffset.dy < 0 || + paintOffset.dx + child!.size.width > size.width || + paintOffset.dy + child!.size.height > size.height; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child != null) { + final paintOffset = _paintOffset; + + void paintContents(PaintingContext context, Offset offset) { + context.paintChild(child!, offset + paintOffset); + } + + if (_shouldClipAtPaintOffset(paintOffset)) { + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + paintContents, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + paintContents(context, offset); + } + } + } + + final _clipRectLayer = LayerHandle(); + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + final paintOffset = _paintOffset; + transform.translate(paintOffset.dx, paintOffset.dy); + } + + @override + Rect? describeApproximatePaintClip(RenderObject? child) { + if (child != null && _shouldClipAtPaintOffset(_paintOffset)) { + return Offset.zero & size; + } + return null; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child != null) { + return result.addWithPaintOffset( + offset: _paintOffset, + position: position, + hitTest: (result, transformed) { + assert(transformed == position + -_paintOffset); + return child!.hitTest(result, position: transformed); + }, + ); + } + return false; + } + + @override + RevealedOffset getOffsetToReveal(RenderObject target, double alignment, + {Rect? rect}) { + rect ??= target.paintBounds; + if (target is! RenderBox) { + return RevealedOffset(offset: offset.pixels, rect: rect); + } + + final targetBox = target; + final transform = targetBox.getTransformTo(child); + final bounds = MatrixUtils.transformRect(transform, rect); + + final double leadingScrollOffset; + final double targetMainAxisExtent; + final double mainAxisExtent; + + mainAxisExtent = size.height; + leadingScrollOffset = bounds.top; + targetMainAxisExtent = bounds.height; + + final targetOffset = leadingScrollOffset - + (mainAxisExtent - targetMainAxisExtent) * alignment; + final targetRect = bounds.shift(_paintOffsetForPosition(targetOffset)); + return RevealedOffset(offset: targetOffset, rect: targetRect); + } + + @override + void showOnScreen({ + RenderObject? descendant, + Rect? rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (!offset.allowImplicitScrolling) { + return super.showOnScreen( + descendant: descendant, + rect: rect, + duration: duration, + curve: curve, + ); + } + + final newRect = RenderViewportBase.showInViewport( + descendant: descendant, + viewport: this, + offset: offset, + rect: rect, + duration: duration, + curve: curve, + ); + super.showOnScreen( + rect: newRect, + duration: duration, + curve: curve, + ); + } + + @override + Rect describeSemanticsClip(RenderObject child) { + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - cacheExtent, + semanticBounds.right, + semanticBounds.bottom + cacheExtent, + ); + } +} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 1f350646..f77f1454 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:convert'; +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'; @@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:tuple/tuple.dart'; +import '../../models/documents/nodes/node.dart'; import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/block.dart'; @@ -21,8 +22,9 @@ import 'default_styles.dart'; import 'delegate.dart'; import 'editor.dart'; import 'keyboard_listener.dart'; +import 'link.dart'; import 'proxy.dart'; -import 'raw_editor/raw_editor_state_keyboard_mixin.dart'; +import 'quill_single_child_scroll_view.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'; @@ -31,66 +33,191 @@ 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, - this.customStyleBuilder, - ) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), + {required this.controller, + required this.focusNode, + required this.scrollController, + required this.scrollBottomInset, + required this.cursorStyle, + required this.selectionColor, + required this.selectionCtrls, + Key? key, + this.scrollable = true, + this.padding = EdgeInsets.zero, + this.readOnly = false, + this.placeholder, + this.onLaunchUrl, + this.toolbarOptions = const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + this.showSelectionHandles = false, + bool? showCursor, + this.textCapitalization = TextCapitalization.none, + this.maxHeight, + this.minHeight, + this.maxContentWidth, + this.customStyles, + this.expands = false, + this.autoFocus = false, + this.keyboardAppearance = Brightness.light, + this.enableInteractiveSelection = true, + this.scrollPhysics, + this.embedBuilder = defaultEmbedBuilder, + this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, + this.customStyleBuilder, + this.floatingCursorDisabled = false}) + : 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); + /// Controls the document being edited. final QuillController controller; + + /// Controls whether this editor has keyboard focus. final FocusNode focusNode; final ScrollController scrollController; final bool scrollable; final double scrollBottomInset; + + /// Additional space around the editor contents. final EdgeInsetsGeometry padding; + + /// Whether the text can be changed. + /// + /// When this is set to true, the text cannot be modified + /// by any shortcut or keyboard operation. The text is still selectable. + /// + /// Defaults to false. Must not be null. final bool readOnly; + final String? placeholder; + + /// Callback which is triggered when the user wants to open a URL from + /// a link in the document. final ValueChanged? onLaunchUrl; + + /// Configuration of toolbar options. + /// + /// By default, all options are enabled. If [readOnly] is true, + /// paste and cut will be disabled regardless. final ToolbarOptions toolbarOptions; + + /// Whether to show selection handles. + /// + /// When a selection is active, there will be two handles at each side of + /// boundary, or one handle if the selection is collapsed. The handles can be + /// dragged to adjust the selection. + /// + /// See also: + /// + /// * [showCursor], which controls the visibility of the cursor. final bool showSelectionHandles; + + /// Whether to show cursor. + /// + /// The cursor refers to the blinking caret when the editor is focused. + /// + /// See also: + /// + /// * [cursorStyle], which controls the cursor visual representation. + /// * [showSelectionHandles], which controls the visibility of the selection + /// handles. final bool showCursor; + + /// The style to be used for the editing cursor. final CursorStyle cursorStyle; + + /// Configures how the platform keyboard will select an uppercase or + /// lowercase keyboard. + /// + /// Only supports text keyboards, other keyboard types will ignore this + /// configuration. Capitalization is locale-aware. + /// + /// Defaults to [TextCapitalization.none]. Must not be null. + /// + /// See also: + /// + /// * [TextCapitalization], for a description of each capitalization behavior final TextCapitalization textCapitalization; + + /// The maximum height this editor can have. + /// + /// If this is null then there is no limit to the editor's height and it will + /// expand to fill its parent. final double? maxHeight; + + /// The minimum height this editor can have. final double? minHeight; + + /// The maximum width to be occupied by the content of this editor. + /// + /// If this is not null and and this editor's width is larger than this value + /// then the contents will be constrained to the provided maximum width and + /// horizontally centered. This is mostly useful on devices with wide screens. + final double? maxContentWidth; + final DefaultStyles? customStyles; + + /// Whether this widget's height will be sized to fill its parent. + /// + /// If set to true and wrapped in a parent widget like [Expanded] or + /// + /// Defaults to false. final bool expands; + + /// Whether this editor should focus itself if nothing else is already + /// focused. + /// + /// If true, the keyboard will open as soon as this text field obtains focus. + /// Otherwise, the keyboard is only shown after the user taps the text field. + /// + /// Defaults to false. Cannot be null. final bool autoFocus; + + /// The color to use when painting the selection. final Color selectionColor; + + /// Delegate for building the text selection handles and toolbar. + /// + /// The [RawEditor] widget used on its own will not trigger the display + /// of the selection toolbar by itself. The toolbar is shown by calling + /// [RawEditorState.showToolbar] in response to an appropriate user event. final TextSelectionControls selectionCtrls; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// Defaults to [Brightness.light]. final Brightness keyboardAppearance; + + /// If true, then long-pressing this TextField will select text and show the + /// cut/copy/paste menu, and tapping will move the text caret. + /// + /// True by default. + /// + /// If false, most of the accessibility support for selecting text, copy + /// and paste, and moving the caret will be disabled. final bool enableInteractiveSelection; + + /// The [ScrollPhysics] to use when vertically scrolling the input. + /// + /// If not specified, it will behave according to the current platform. + /// + /// See [Scrollable.physics]. final ScrollPhysics? scrollPhysics; + + /// Builder function for embeddable objects. final EmbedBuilder embedBuilder; + final LinkActionPickerDelegate linkActionPickerDelegate; final CustomStyleBuilder? customStyleBuilder; + final bool floatingCursorDisabled; + @override State createState() => RawEditorState(); } @@ -100,13 +227,11 @@ class RawEditorState extends EditorState AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, - RawEditorStateKeyboardMixin, + TextEditingActionTarget, RawEditorStateTextInputClientMixin, RawEditorStateSelectionDelegateMixin { final GlobalKey _editorKey = GlobalKey(); - // Keyboard - late KeyboardListener _keyboardListener; KeyboardVisibilityController? _keyboardVisibilityController; StreamSubscription? _keyboardVisibilitySubscription; bool _keyboardVisible = false; @@ -120,6 +245,7 @@ class RawEditorState extends EditorState ScrollController get scrollController => _scrollController; late ScrollController _scrollController; + // Cursors late CursorCont _cursorCont; // Focus @@ -127,6 +253,7 @@ class RawEditorState extends EditorState FocusAttachment? _focusAttachment; bool get _hasFocus => widget.focusNode.hasFocus; + // Theme DefaultStyles? _styles; final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); @@ -156,12 +283,15 @@ class RawEditorState extends EditorState document: _doc, selection: widget.controller.selection, hasFocus: _hasFocus, + cursorController: _cursorCont, textDirection: _textDirection, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, onSelectionChanged: _handleSelectionChanged, scrollBottomInset: widget.scrollBottomInset, padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + floatingCursorDisabled: widget.floatingCursorDisabled, children: _buildChildren(_doc, context), ), ), @@ -173,10 +303,29 @@ class RawEditorState extends EditorState child = BaselineProxy( textStyle: _styles!.paragraph!.style, padding: baselinePadding, - child: SingleChildScrollView( + child: QuillSingleChildScrollView( controller: _scrollController, physics: widget.scrollPhysics, - child: child, + viewportBuilder: (_, offset) => CompositedTransformTarget( + link: _toolbarLayerLink, + child: _Editor( + key: _editorKey, + offset: offset, + document: widget.controller.document, + selection: widget.controller.selection, + hasFocus: _hasFocus, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + cursorController: _cursorCont, + floatingCursorDisabled: widget.floatingCursorDisabled, + children: _buildChildren(_doc, context), + ), + ), ), ); } @@ -191,9 +340,11 @@ class RawEditorState extends EditorState data: _styles!, child: MouseRegion( cursor: SystemMouseCursors.text, - child: Container( - constraints: constraints, - child: child, + child: QuillKeyboardListener( + child: Container( + constraints: constraints, + child: child, + ), ), ), ); @@ -201,24 +352,34 @@ class RawEditorState extends EditorState void _handleSelectionChanged( TextSelection selection, SelectionChangedCause cause) { + final oldSelection = widget.controller.selection; widget.controller.updateSelection(selection, ChangeSource.LOCAL); _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); if (!_keyboardVisible) { + // This will show the keyboard for all selection changes on the + // editor, not just changes triggered by user gestures. requestKeyboard(); } + + if (cause == SelectionChangedCause.drag) { + // When user updates the selection while dragging make sure to + // bring the updated position (base or extent) into view. + if (oldSelection.baseOffset != selection.baseOffset) { + bringIntoView(selection.base); + } else if (oldSelection.extentOffset != selection.extentOffset) { + bringIntoView(selection.extent); + } + } } /// 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); - } + widget.controller.formatText( + offset, 0, value ? Attribute.checked : Attribute.unchecked); } } @@ -233,6 +394,7 @@ class RawEditorState extends EditorState final attrs = node.style.attributes; final editableTextBlock = EditableTextBlock( block: node, + controller: widget.controller, textDirection: _textDirection, scrollBottomInset: widget.scrollBottomInset, verticalSpacing: _getVerticalSpacingForBlock(node, _styles), @@ -245,6 +407,8 @@ class RawEditorState extends EditorState ? const EdgeInsets.all(16) : null, embedBuilder: widget.embedBuilder, + linkActionPicker: _linkActionPicker, + onLaunchUrl: widget.onLaunchUrl, cursorCont: _cursorCont, indentLevelCounts: indentLevelCounts, onCheckboxTap: _handleCheckboxTap, @@ -267,6 +431,9 @@ class RawEditorState extends EditorState customStyleBuilder: widget.customStyleBuilder, styles: _styles!, readOnly: widget.readOnly, + controller: widget.controller, + linkActionPicker: _linkActionPicker, + onLaunchUrl: widget.onLaunchUrl, ); final editableTextLine = EditableTextLine( node, @@ -313,8 +480,12 @@ class RawEditorState extends EditorState return defaultStyles!.code!.verticalSpacing; } else if (attrs.containsKey(Attribute.indent.key)) { return defaultStyles!.indent!.verticalSpacing; + } else if (attrs.containsKey(Attribute.list.key)) { + return defaultStyles!.lists!.verticalSpacing; + } else if (attrs.containsKey(Attribute.align.key)) { + return defaultStyles!.align!.verticalSpacing; } - return defaultStyles!.lists!.verticalSpacing; + return const Tuple2(0, 0); } @override @@ -336,11 +507,9 @@ class RawEditorState extends EditorState tickerProvider: this, ); - _keyboardListener = KeyboardListener( - handleCursorMovement, - handleShortcut, - handleDelete, - ); + // Floating cursor + _floatingCursorResetController = AnimationController(vsync: this); + _floatingCursorResetController.addListener(onFloatingCursorResetTick); if (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS || @@ -354,13 +523,12 @@ class RawEditorState extends EditorState _keyboardVisibilityController?.onChange.listen((visible) { _keyboardVisible = visible; if (visible) { - _onChangeTextEditingValue(); + _onChangeTextEditingValue(!_hasFocus); } }); } - _focusAttachment = widget.focusNode.attach(context, - onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); + _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); } @@ -405,8 +573,7 @@ class RawEditorState extends EditorState if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocusChanged); _focusAttachment?.detach(); - _focusAttachment = widget.focusNode.attach(context, - onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); + _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); updateKeepAlive(); } @@ -448,7 +615,7 @@ class RawEditorState extends EditorState } void _updateSelectionOverlayForScroll() { - _selectionOverlay?.markNeedsBuild(); + _selectionOverlay?.updateForScroll(); } void _didChangeTextEditingValue([bool ignoreFocus = false]) { @@ -482,11 +649,19 @@ class RawEditorState extends EditorState _cursorCont.startOrStopCursorTimerIfNeeded( _hasFocus, widget.controller.selection); if (hasConnection) { + // To keep the cursor from blinking while typing, we want to restart the + // cursor timer every time a new character is typed. _cursorCont ..stopCursorTimer(resetCharTicks: false) ..startCursorTimer(); } + // Refresh selection overlay after the build step had a chance to + // update and register all children of RenderEditor. Otherwise this will + // fail in situations where a new line of text is entered, which adds + // a new RenderEditableBox child. If we try to update selection overlay + // immediately it'll not be able to find the new child since it hasn't been + // built yet. SchedulerBinding.instance!.addPostFrameCallback((_) { if (!mounted) { return; @@ -503,7 +678,7 @@ class RawEditorState extends EditorState void _updateOrDisposeSelectionOverlayIfNeeded() { if (_selectionOverlay != null) { - if (_hasFocus) { + if (_hasFocus && !textEditingValue.selection.isCollapsed) { _selectionOverlay!.update(textEditingValue); } else { _selectionOverlay!.dispose(); @@ -511,22 +686,18 @@ class RawEditorState extends EditorState } } else if (_hasFocus) { _selectionOverlay?.hide(); - _selectionOverlay = null; _selectionOverlay = EditorTextSelectionOverlay( - textEditingValue, - false, - context, - widget, - _toolbarLayerLink, - _startHandleLayerLink, - _endHandleLayerLink, - getRenderEditor(), - widget.selectionCtrls, - this, - DragStartBehavior.start, - null, - _clipboardStatus, + value: textEditingValue, + context: context, + debugRequiredFor: widget, + toolbarLayerLink: _toolbarLayerLink, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: getRenderEditor(), + selectionCtrls: widget.selectionCtrls, + selectionDelegate: this, + clipboardStatus: _clipboardStatus, ); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.showHandles(); @@ -555,6 +726,11 @@ class RawEditorState extends EditorState }); } + Future _linkActionPicker(Node linkNode) async { + final link = linkNode.style.attributes[Attribute.link.key]!.value!; + return widget.linkActionPickerDelegate(context, link); + } + bool _showCaretOnScreenScheduled = false; void _showCaretOnScreen() { @@ -564,7 +740,7 @@ class RawEditorState extends EditorState _showCaretOnScreenScheduled = true; SchedulerBinding.instance!.addPostFrameCallback((_) { - if (widget.scrollable) { + if (widget.scrollable || _scrollController.hasClients) { _showCaretOnScreenScheduled = false; final renderEditor = getRenderEditor(); @@ -585,7 +761,7 @@ class RawEditorState extends EditorState if (offset != null) { _scrollController.animateTo( - offset, + math.min(offset, _scrollController.position.maxScrollExtent), duration: const Duration(milliseconds: 100), curve: Curves.fastOutSlowIn, ); @@ -599,68 +775,35 @@ class RawEditorState extends EditorState return _editorKey.currentContext?.findRenderObject() as RenderEditor?; } - @override - TextEditingValue getTextEditingValue() { - return widget.controller.plainTextEditingValue; - } - @override void requestKeyboard() { if (_hasFocus) { openConnectionIfNeeded(); + _showCaretOnScreen(); } else { widget.focusNode.requestFocus(); } } @override - void setTextEditingValue(TextEditingValue value) { - if (value.text == textEditingValue.text) { - widget.controller.updateSelection(value.selection, ChangeSource.LOCAL); - } else { - _setEditingValue(value); - } - } - - // set editing value from clipboard for mobile - 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); - } + void setTextEditingValue( + TextEditingValue value, SelectionChangedCause cause) { + if (value == textEditingValue) { + return; } + textEditingValue = value; + userUpdateTextEditingValue(value, cause); } - 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 + void debugAssertLayoutUpToDate() { + getRenderEditor()!.debugAssertLayoutUpToDate(); } + /// Shows the selection toolbar at the location of the current cursor. + /// + /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar + /// is already shown, or when no text selection currently exists. @override bool showToolbar() { // Web is using native dom elements to enable clipboard functionality of the @@ -679,8 +822,85 @@ class RawEditorState extends EditorState return true; } + @override + void copySelection(SelectionChangedCause cause) { + // Copied straight from EditableTextState + super.copySelection(cause); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(false); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + break; + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Collapse the selection and hide the toolbar and handles. + userUpdateTextEditingValue( + TextEditingValue( + text: textEditingValue.text, + selection: TextSelection.collapsed( + offset: textEditingValue.selection.end), + ), + SelectionChangedCause.toolbar, + ); + break; + } + } + } + + @override + void cutSelection(SelectionChangedCause cause) { + // Copied straight from EditableTextState + super.cutSelection(cause); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(); + } + } + + @override + Future pasteText(SelectionChangedCause cause) async { + // Copied straight from EditableTextState + super.pasteText(cause); // ignore: unawaited_futures + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(); + } + } + + @override + void selectAll(SelectionChangedCause cause) { + // Copied straight from EditableTextState + super.selectAll(cause); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + } + } + @override bool get wantKeepAlive => widget.focusNode.hasFocus; + + @override + bool get obscureText => false; + + @override + bool get selectionEnabled => widget.enableInteractiveSelection; + + @override + bool get readOnly => widget.readOnly; + + @override + TextLayoutMetrics get textLayoutMetrics => getRenderEditor()!; + + @override + AnimationController get floatingCursorResetController => + _floatingCursorResetController; + + late AnimationController _floatingCursorResetController; } class _Editor extends MultiChildRenderObjectWidget { @@ -695,9 +915,14 @@ class _Editor extends MultiChildRenderObjectWidget { required this.endHandleLayerLink, required this.onSelectionChanged, required this.scrollBottomInset, + required this.cursorController, + required this.floatingCursorDisabled, this.padding = EdgeInsets.zero, + this.maxContentWidth, + this.offset, }) : super(key: key, children: children); + final ViewportOffset? offset; final Document document; final TextDirection textDirection; final bool hasFocus; @@ -707,28 +932,33 @@ class _Editor extends MultiChildRenderObjectWidget { final TextSelectionChangedHandler onSelectionChanged; final double scrollBottomInset; final EdgeInsetsGeometry padding; + final double? maxContentWidth; + final CursorCont cursorController; + final bool floatingCursorDisabled; @override RenderEditor createRenderObject(BuildContext context) { return RenderEditor( - null, - textDirection, - scrollBottomInset, - padding, - document, - selection, - hasFocus, - onSelectionChanged, - startHandleLayerLink, - endHandleLayerLink, - const EdgeInsets.fromLTRB(4, 4, 4, 5), - ); + offset: offset, + document: document, + textDirection: textDirection, + hasFocus: hasFocus, + selection: selection, + startHandleLayerLink: startHandleLayerLink, + endHandleLayerLink: endHandleLayerLink, + onSelectionChanged: onSelectionChanged, + cursorController: cursorController, + padding: padding, + maxContentWidth: maxContentWidth, + scrollBottomInset: scrollBottomInset, + floatingCursorDisabled: floatingCursorDisabled); } @override void updateRenderObject( BuildContext context, covariant RenderEditor renderObject) { renderObject + ..offset = offset ..document = document ..setContainer(document.root) ..textDirection = textDirection @@ -738,6 +968,7 @@ class _Editor extends MultiChildRenderObjectWidget { ..setEndHandleLayerLink(endHandleLayerLink) ..onSelectionChanged = onSelectionChanged ..setScrollBottomInset(scrollBottomInset) - ..setPadding(padding); + ..setPadding(padding) + ..maxContentWidth = maxContentWidth; } } 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 deleted file mode 100644 index 9ba6502d..00000000 --- a/lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart +++ /dev/null @@ -1,355 +0,0 @@ -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) - // set editing value from clipboard for web - 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 index dcb9809a..8c8e64c9 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 @@ -1,20 +1,46 @@ -import 'dart:math'; +import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import '../../../utils/diff_delta.dart'; import '../editor.dart'; mixin RawEditorStateSelectionDelegateMixin on EditorState implements TextSelectionDelegate { @override TextEditingValue get textEditingValue { - return getTextEditingValue(); + return widget.controller.plainTextEditingValue; } @override set textEditingValue(TextEditingValue value) { - setTextEditingValue(value); + final cursorPosition = value.selection.extentOffset; + final oldText = widget.controller.document.toPlainText(); + final newText = value.text; + final diff = getDiff(oldText, newText, cursorPosition); + final insertedText = _adjustInsertedText(diff.inserted); + + widget.controller.replaceText( + diff.start, diff.deleted.length, insertedText, value.selection); + } + + String _adjustInsertedText(String text) { + // For clip from editor, it may contain image, a.k.a 65532. + // For clip from browser, image is directly ignore. + // Here we skip image when pasting. + if (!text.codeUnits.contains(65532)) { + return text; + } + + final sb = StringBuffer(); + for (var i = 0; i < text.length; i++) { + if (text.codeUnitAt(i) == 65532) { + continue; + } + sb.write(text[i]); + } + return sb.toString(); } @override @@ -50,8 +76,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState final expandedRect = Rect.fromCenter( center: rect.center, width: rect.width, - height: - max(rect.height, getRenderEditor()!.preferredLineHeight(position)), + height: math.max( + rect.height, getRenderEditor()!.preferredLineHeight(position)), ); additionalOffset = expandedRect.height >= editableSize.height @@ -81,10 +107,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState @override void userUpdateTextEditingValue( - TextEditingValue value, - SelectionChangedCause cause, - ) { - setTextEditingValue(value); + TextEditingValue value, SelectionChangedCause cause) { + textEditingValue = value; } @override 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 bfb36106..a9923d27 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 @@ -1,13 +1,15 @@ +import 'dart:ui'; + +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; +import '../../models/documents/document.dart'; import '../../utils/diff_delta.dart'; import '../editor.dart'; mixin RawEditorStateTextInputClientMixin on EditorState implements TextInputClient { - final List _sentRemoteValues = []; TextInputConnection? _textInputConnection; TextEditingValue? _lastKnownRemoteTextEditingValue; @@ -46,7 +48,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState } if (!hasConnection) { - _lastKnownRemoteTextEditingValue = getTextEditingValue(); + _lastKnownRemoteTextEditingValue = textEditingValue; _textInputConnection = TextInput.attach( this, TextInputConfiguration( @@ -74,7 +76,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState _textInputConnection!.close(); _textInputConnection = null; _lastKnownRemoteTextEditingValue = null; - _sentRemoteValues.clear(); } /// Updates remote value based on current state of [document] and @@ -87,12 +88,14 @@ mixin RawEditorStateTextInputClientMixin on EditorState return; } + final value = textEditingValue; + // 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( + final actualValue = value.copyWith( composing: _lastKnownRemoteTextEditingValue!.composing, ); @@ -100,18 +103,12 @@ mixin RawEditorStateTextInputClientMixin on EditorState return; } - final shouldRemember = - getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text; _lastKnownRemoteTextEditingValue = 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); - } } @override @@ -128,22 +125,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState 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; @@ -167,8 +148,12 @@ mixin RawEditorStateTextInputClientMixin on EditorState 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); + if (diff.deleted.isEmpty && diff.inserted.isEmpty) { + widget.controller.updateSelection(value.selection, ChangeSource.LOCAL); + } else { + widget.controller.replaceText( + diff.start, diff.deleted.length, diff.inserted, value.selection); + } } @override @@ -181,9 +166,119 @@ mixin RawEditorStateTextInputClientMixin on EditorState // no-op } + // The time it takes for the floating cursor to snap to the text aligned + // cursor position after the user has finished placing it. + static const Duration _floatingCursorResetTime = Duration(milliseconds: 125); + + // The original position of the caret on FloatingCursorDragState.start. + Rect? _startCaretRect; + + // The most recent text position as determined by the location of the floating + // cursor. + TextPosition? _lastTextPosition; + + // The offset of the floating cursor as determined from the start call. + Offset? _pointOffsetOrigin; + + // The most recent position of the floating cursor. + Offset? _lastBoundedOffset; + + // Because the center of the cursor is preferredLineHeight / 2 below the touch + // origin, but the touch origin is used to determine which line the cursor is + // on, we need this offset to correctly render and move the cursor. + Offset _floatingCursorOffset(TextPosition textPosition) => + Offset(0, getRenderEditor()!.preferredLineHeight(textPosition) / 2); + @override void updateFloatingCursor(RawFloatingCursorPoint point) { - throw UnimplementedError(); + switch (point.state) { + case FloatingCursorDragState.Start: + if (floatingCursorResetController.isAnimating) { + floatingCursorResetController.stop(); + onFloatingCursorResetTick(); + } + // We want to send in points that are centered around a (0,0) origin, so + // we cache the position. + _pointOffsetOrigin = point.offset; + + final currentTextPosition = + TextPosition(offset: getRenderEditor()!.selection.baseOffset); + _startCaretRect = + getRenderEditor()!.getLocalRectForCaret(currentTextPosition); + + _lastBoundedOffset = _startCaretRect!.center - + _floatingCursorOffset(currentTextPosition); + _lastTextPosition = currentTextPosition; + getRenderEditor()!.setFloatingCursor( + point.state, _lastBoundedOffset!, _lastTextPosition!); + break; + case FloatingCursorDragState.Update: + assert(_lastTextPosition != null, 'Last text position was not set'); + final floatingCursorOffset = _floatingCursorOffset(_lastTextPosition!); + final centeredPoint = point.offset! - _pointOffsetOrigin!; + final rawCursorOffset = + _startCaretRect!.center + centeredPoint - floatingCursorOffset; + + final preferredLineHeight = + getRenderEditor()!.preferredLineHeight(_lastTextPosition!); + _lastBoundedOffset = + getRenderEditor()!.calculateBoundedFloatingCursorOffset( + rawCursorOffset, + preferredLineHeight, + ); + _lastTextPosition = getRenderEditor()!.getPositionForOffset( + getRenderEditor()! + .localToGlobal(_lastBoundedOffset! + floatingCursorOffset)); + getRenderEditor()!.setFloatingCursor( + point.state, _lastBoundedOffset!, _lastTextPosition!); + final newSelection = TextSelection.collapsed( + offset: _lastTextPosition!.offset, + affinity: _lastTextPosition!.affinity); + // Setting selection as floating cursor moves will have scroll view + // bring background cursor into view + getRenderEditor()! + .onSelectionChanged(newSelection, SelectionChangedCause.forcePress); + break; + case FloatingCursorDragState.End: + // We skip animation if no update has happened. + if (_lastTextPosition != null && _lastBoundedOffset != null) { + floatingCursorResetController + ..value = 0.0 + ..animateTo(1, + duration: _floatingCursorResetTime, curve: Curves.decelerate); + } + break; + } + } + + /// Specifies the floating cursor dimensions and position based + /// the animation controller value. + /// The floating cursor is resized + /// (see [RenderAbstractEditor.setFloatingCursor]) + /// and repositioned (linear interpolation between position of floating cursor + /// and current position of background cursor) + void onFloatingCursorResetTick() { + final finalPosition = + getRenderEditor()!.getLocalRectForCaret(_lastTextPosition!).centerLeft - + _floatingCursorOffset(_lastTextPosition!); + if (floatingCursorResetController.isCompleted) { + getRenderEditor()!.setFloatingCursor( + FloatingCursorDragState.End, finalPosition, _lastTextPosition!); + _startCaretRect = null; + _lastTextPosition = null; + _pointOffsetOrigin = null; + _lastBoundedOffset = null; + } else { + final lerpValue = floatingCursorResetController.value; + final lerpX = + lerpDouble(_lastBoundedOffset!.dx, finalPosition.dx, lerpValue)!; + final lerpY = + lerpDouble(_lastBoundedOffset!.dy, finalPosition.dy, lerpValue)!; + + getRenderEditor()!.setFloatingCursor(FloatingCursorDragState.Update, + Offset(lerpX, lerpY), _lastTextPosition!, + resetLerpValue: lerpValue); + } } @override @@ -199,6 +294,5 @@ mixin RawEditorStateTextInputClientMixin on EditorState _textInputConnection!.connectionClosedReceived(); _textInputConnection = null; _lastKnownRemoteTextEditingValue = null; - _sentRemoteValues.clear(); } } diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart deleted file mode 100644 index 2d5fa451..00000000 --- a/lib/src/widgets/simple_viewer.dart +++ /dev/null @@ -1,358 +0,0 @@ -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'; -import 'video_app.dart'; -import 'youtube_video_app.dart'; - -class QuillSimpleViewer extends StatefulWidget { - const QuillSimpleViewer({ - required this.controller, - required this.readOnly, - 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; - final bool readOnly; - - @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, bool readOnly) { - 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)); - case 'video': - final videoUrl = node.value.data; - if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { - return YoutubeVideoApp( - videoUrl: videoUrl, context: context, readOnly: readOnly); - } - return VideoApp( - videoUrl: videoUrl, context: context, readOnly: readOnly); - 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( - block: node, - textDirection: _textDirection, - scrollBottomInset: widget.scrollBottomInset, - verticalSpacing: _getVerticalSpacingForBlock(node, _styles), - textSelection: widget.controller.selection, - color: Colors.black, - styles: _styles, - enableInteractiveSelection: false, - hasFocus: false, - contentPadding: attrs.containsKey(Attribute.codeBlock.key) - ? const EdgeInsets.all(16) - : null, - embedBuilder: embedBuilder, - cursorCont: _cursorCont, - indentLevelCounts: indentLevelCounts, - onCheckboxTap: _handleCheckboxTap, - readOnly: widget.readOnly); - 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, - readOnly: widget.readOnly, - ); - 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/style_widgets/bullet_point.dart b/lib/src/widgets/style_widgets/bullet_point.dart new file mode 100644 index 00000000..ee33c93a --- /dev/null +++ b/lib/src/widgets/style_widgets/bullet_point.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class QuillBulletPoint extends StatelessWidget { + const QuillBulletPoint({ + 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), + ); + } +} diff --git a/lib/src/widgets/style_widgets/checkbox_point.dart b/lib/src/widgets/style_widgets/checkbox_point.dart new file mode 100644 index 00000000..7f4bfe88 --- /dev/null +++ b/lib/src/widgets/style_widgets/checkbox_point.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +class CheckboxPoint extends StatefulWidget { + const CheckboxPoint({ + required this.size, + required this.value, + required this.enabled, + required this.onChanged, + this.uiBuilder, + Key? key, + }) : super(key: key); + + final double size; + final bool value; + final bool enabled; + final ValueChanged onChanged; + final QuillCheckboxBuilder? uiBuilder; + + @override + _CheckboxPointState createState() => _CheckboxPointState(); +} + +class _CheckboxPointState extends State { + @override + Widget build(BuildContext context) { + if (widget.uiBuilder != null) { + return widget.uiBuilder!.build( + context: context, + isChecked: widget.value, + onChanged: widget.onChanged, + ); + } + final theme = Theme.of(context); + final fillColor = widget.value + ? (widget.enabled + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withOpacity(0.5)) + : theme.colorScheme.surface; + final borderColor = widget.value + ? (widget.enabled + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withOpacity(0)) + : (widget.enabled + ? theme.colorScheme.onSurface.withOpacity(0.5) + : theme.colorScheme.onSurface.withOpacity(0.3)); + return Center( + child: SizedBox( + width: widget.size, + height: widget.size, + child: Material( + color: fillColor, + shape: RoundedRectangleBorder( + side: BorderSide( + color: borderColor, + ), + borderRadius: BorderRadius.circular(2), + ), + child: InkWell( + onTap: + widget.enabled ? () => widget.onChanged(!widget.value) : null, + child: widget.value + ? Icon(Icons.check, + size: widget.size, color: theme.colorScheme.onPrimary) + : null, + ), + ), + ), + ); + } +} + +abstract class QuillCheckboxBuilder { + Widget build({ + required BuildContext context, + required bool isChecked, + required ValueChanged onChanged, + }); +} diff --git a/lib/src/widgets/style_widgets/number_point.dart b/lib/src/widgets/style_widgets/number_point.dart new file mode 100644 index 00000000..f1ffddf1 --- /dev/null +++ b/lib/src/widgets/style_widgets/number_point.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../text_block.dart'; + +class QuillNumberPoint extends StatelessWidget { + const QuillNumberPoint({ + 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(); + } +} diff --git a/lib/src/widgets/style_widgets/style_widgets.dart b/lib/src/widgets/style_widgets/style_widgets.dart new file mode 100644 index 00000000..c28606bc --- /dev/null +++ b/lib/src/widgets/style_widgets/style_widgets.dart @@ -0,0 +1,3 @@ +export 'bullet_point.dart'; +export 'checkbox_point.dart'; +export 'number_point.dart'; diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index e668d30b..819ebe07 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -1,16 +1,14 @@ -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 '../../flutter_quill.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 'link.dart'; import 'text_line.dart'; import 'text_selection.dart'; @@ -49,6 +47,7 @@ const List romanNumbers = [ class EditableTextBlock extends StatelessWidget { const EditableTextBlock( {required this.block, + required this.controller, required this.textDirection, required this.scrollBottomInset, required this.verticalSpacing, @@ -59,14 +58,17 @@ class EditableTextBlock extends StatelessWidget { required this.hasFocus, required this.contentPadding, required this.embedBuilder, + required this.linkActionPicker, required this.cursorCont, required this.indentLevelCounts, required this.onCheckboxTap, required this.readOnly, + this.onLaunchUrl, this.customStyleBuilder, Key? key}); final Block block; + final QuillController controller; final TextDirection textDirection; final double scrollBottomInset; final Tuple2 verticalSpacing; @@ -77,6 +79,8 @@ class EditableTextBlock extends StatelessWidget { final bool hasFocus; final EdgeInsets? contentPadding; final EmbedBuilder embedBuilder; + final LinkActionPicker linkActionPicker; + final ValueChanged? onLaunchUrl; final CustomStyleBuilder? customStyleBuilder; final CursorCont cursorCont; final Map indentLevelCounts; @@ -128,6 +132,9 @@ class EditableTextBlock extends StatelessWidget { customStyleBuilder: customStyleBuilder, styles: styles!, readOnly: readOnly, + controller: controller, + linkActionPicker: linkActionPicker, + onLaunchUrl: onLaunchUrl, ), _getIndentWidth(), _getSpacingForLine(line, index, count, defaultStyles), @@ -148,7 +155,7 @@ class EditableTextBlock extends StatelessWidget { final defaultStyles = QuillStyles.getStyles(context, false); final attrs = line.style.attributes; if (attrs[Attribute.list.key] == Attribute.ol) { - return _NumberPoint( + return QuillNumberPoint( index: index, indentLevelCounts: indentLevelCounts, count: count, @@ -160,7 +167,7 @@ class EditableTextBlock extends StatelessWidget { } if (attrs[Attribute.list.key] == Attribute.ul) { - return _BulletPoint( + return QuillBulletPoint( style: defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), width: 32, @@ -168,28 +175,25 @@ class EditableTextBlock extends StatelessWidget { } 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, + return CheckboxPoint( + size: 14, + value: true, + enabled: !readOnly, + onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), ); } if (attrs[Attribute.list.key] == Attribute.unchecked) { - return _Checkbox( - key: UniqueKey(), - style: defaultStyles!.leading!.style, - width: 32, - offset: block.offset + line.offset, - onTap: onCheckboxTap, + return CheckboxPoint( + size: 14, + value: false, + enabled: !readOnly, + onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), ); } if (attrs.containsKey(Attribute.codeBlock.key)) { - return _NumberPoint( + return QuillNumberPoint( index: index, indentLevelCounts: indentLevelCounts, count: count, @@ -217,7 +221,14 @@ class EditableTextBlock extends StatelessWidget { return 16.0 + extraIndent; } - return 32.0 + extraIndent; + var baseIndent = 0.0; + + if (attrs.containsKey(Attribute.list.key) || + attrs.containsKey(Attribute.codeBlock.key)) { + baseIndent = 32.0; + } + + return baseIndent + extraIndent; } Tuple2 _getSpacingForLine( @@ -551,6 +562,16 @@ class RenderEditableTextBlock extends RenderEditableContainerBox affinity: position.affinity, ); } + + @override + Rect getCaretPrototype(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.getContainer().offset, + affinity: position.affinity, + ); + return child.getCaretPrototype(localPosition); + } } class _EditableBlock extends MultiChildRenderObjectWidget { @@ -600,166 +621,3 @@ class _EditableBlock extends MultiChildRenderObjectWidget { ..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 index 44715f6e..db0fc973 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -2,30 +2,37 @@ import 'dart:collection'; import 'dart:math' as math; 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:tuple/tuple.dart'; +import 'package:url_launcher/url_launcher.dart'; -import '../models/documents/attribute.dart'; +import '../../flutter_quill.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 '../models/documents/style.dart'; import '../utils/color.dart'; import 'box.dart'; import 'cursor.dart'; -import 'default_styles.dart'; import 'delegate.dart'; +import 'keyboard_listener.dart'; +import 'link.dart'; import 'proxy.dart'; import 'text_selection.dart'; -class TextLine extends StatelessWidget { +class TextLine extends StatefulWidget { const TextLine({ required this.line, required this.embedBuilder, required this.styles, required this.readOnly, + required this.controller, + required this.onLaunchUrl, + required this.linkActionPicker, this.textDirection, this.customStyleBuilder, Key? key, @@ -36,56 +43,141 @@ class TextLine extends StatelessWidget { final EmbedBuilder embedBuilder; final DefaultStyles styles; final bool readOnly; + final QuillController controller; final CustomStyleBuilder? customStyleBuilder; + final ValueChanged? onLaunchUrl; + final LinkActionPicker linkActionPicker; + + @override + State createState() => _TextLineState(); +} + +class _TextLineState extends State { + bool _metaOrControlPressed = false; + + UniqueKey _richTextKey = UniqueKey(); + + final _linkRecognizers = {}; + + QuillPressedKeys? _pressedKeys; + + void _pressedKeysChanged() { + final newValue = _pressedKeys!.metaPressed || _pressedKeys!.controlPressed; + if (_metaOrControlPressed != newValue) { + setState(() { + _metaOrControlPressed = newValue; + _richTextKey = UniqueKey(); + }); + } + } + + bool get isDesktop => { + TargetPlatform.macOS, + TargetPlatform.linux, + TargetPlatform.windows + }.contains(defaultTargetPlatform); + + bool get canLaunchLinks { + // In readOnly mode users can launch links + // by simply tapping (clicking) on them + if (widget.readOnly) return true; + + // In editing mode it depends on the platform: + + // Desktop platforms (macos, linux, windows): + // only allow Meta(Control)+Click combinations + if (isDesktop) { + return _metaOrControlPressed; + } + // Mobile platforms (ios, android): always allow but we install a + // long-press handler instead of a tap one. LongPress is followed by a + // context menu with actions. + return true; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_pressedKeys == null) { + _pressedKeys = QuillPressedKeys.of(context); + _pressedKeys!.addListener(_pressedKeysChanged); + } else { + _pressedKeys!.removeListener(_pressedKeysChanged); + _pressedKeys = QuillPressedKeys.of(context); + _pressedKeys!.addListener(_pressedKeysChanged); + } + } + + @override + void didUpdateWidget(covariant TextLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.readOnly != widget.readOnly) { + _richTextKey = UniqueKey(); + _linkRecognizers + ..forEach((key, value) { + value.dispose(); + }) + ..clear(); + } + } + + @override + void dispose() { + _pressedKeys?.removeListener(_pressedKeysChanged); + _linkRecognizers + ..forEach((key, value) => value.dispose()) + ..clear(); + super.dispose(); + } @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); - if (line.hasEmbed && line.childCount == 1) { + if (widget.line.hasEmbed && widget.line.childCount == 1) { // For video, it is always single child - final embed = line.children.single as Embed; - return EmbedProxy(embedBuilder(context, embed, readOnly)); + final embed = widget.line.children.single as Embed; + return EmbedProxy(widget.embedBuilder(context, embed, widget.readOnly)); } final textSpan = _getTextSpanForWholeLine(context); final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); final textAlign = _getTextAlign(); final child = RichText( + key: _richTextKey, text: textSpan, textAlign: textAlign, - textDirection: textDirection, + textDirection: widget.textDirection, strutStyle: strutStyle, textScaleFactor: MediaQuery.textScaleFactorOf(context), ); return RichTextProxy( - child, - textSpan.style!, - textAlign, - textDirection!, - 1, - Localizations.localeOf(context), - strutStyle, - TextWidthBasis.parent, - null); + textStyle: textSpan.style!, + textAlign: textAlign, + textDirection: widget.textDirection!, + strutStyle: strutStyle, + locale: Localizations.localeOf(context), + child: child); } InlineSpan _getTextSpanForWholeLine(BuildContext context) { - final lineStyle = _getLineStyle(styles); - if (!line.hasEmbed) { - return _buildTextSpan(styles, line.children, lineStyle); + final lineStyle = _getLineStyle(widget.styles); + if (!widget.line.hasEmbed) { + return _buildTextSpan(widget.styles, widget.line.children, lineStyle); } // The line could contain more than one Embed & more than one Text final textSpanChildren = []; var textNodes = LinkedList(); - for (final child in line.children) { + for (final child in widget.line.children) { if (child is Embed) { if (textNodes.isNotEmpty) { - textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle)); + textSpanChildren + .add(_buildTextSpan(widget.styles, textNodes, lineStyle)); textNodes = LinkedList(); } // Here it should be image final embed = WidgetSpan( - child: EmbedProxy(embedBuilder(context, child, readOnly))); + child: EmbedProxy( + widget.embedBuilder(context, child, widget.readOnly))); textSpanChildren.add(embed); continue; } @@ -95,20 +187,20 @@ class TextLine extends StatelessWidget { } if (textNodes.isNotEmpty) { - textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle)); + textSpanChildren.add(_buildTextSpan(widget.styles, textNodes, lineStyle)); } return TextSpan(style: lineStyle, children: textSpanChildren); } TextAlign _getTextAlign() { - final alignment = line.style.attributes[Attribute.align.key]; + final alignment = widget.line.style.attributes[Attribute.align.key]; if (alignment == Attribute.leftAlignment) { - return TextAlign.left; + return TextAlign.start; } else if (alignment == Attribute.centerAlignment) { return TextAlign.center; } else if (alignment == Attribute.rightAlignment) { - return TextAlign.right; + return TextAlign.end; } else if (alignment == Attribute.justifyAlignment) { return TextAlign.justify; } @@ -118,7 +210,8 @@ class TextLine extends StatelessWidget { TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList nodes, TextStyle lineStyle) { final children = nodes - .map((node) => _getTextSpanFromNode(defaultStyles, node)) + .map((node) => + _getTextSpanFromNode(defaultStyles, node, widget.line.style)) .toList(growable: false); return TextSpan(children: children, style: lineStyle); @@ -127,11 +220,11 @@ class TextLine extends StatelessWidget { TextStyle _getLineStyle(DefaultStyles defaultStyles) { var textStyle = const TextStyle(); - if (line.style.containsKey(Attribute.placeholder.key)) { + if (widget.line.style.containsKey(Attribute.placeholder.key)) { return defaultStyles.placeHolder!.style; } - final header = line.style.attributes[Attribute.header.key]; + final header = widget.line.style.attributes[Attribute.header.key]; final m = { Attribute.h1: defaultStyles.h1!.style, Attribute.h2: defaultStyles.h2!.style, @@ -140,52 +233,75 @@ class TextLine extends StatelessWidget { textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); - final block = line.style.getBlockExceptHeader(); + // Only retrieve exclusive block format for the line style purpose + Attribute? block; + widget.line.style.getBlocksExceptHeader().forEach((key, value) { + if (Attribute.exclusiveBlockKeys.contains(key)) { + block = value; + } + }); + TextStyle? toMerge; if (block == Attribute.blockQuote) { toMerge = defaultStyles.quote!.style; } else if (block == Attribute.codeBlock) { toMerge = defaultStyles.code!.style; - } else if (block != null) { + } else if (block == Attribute.list) { toMerge = defaultStyles.lists!.style; } textStyle = textStyle.merge(toMerge); - textStyle = _applyCustomAttributes(textStyle, line.style.attributes); + textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes); return textStyle; } TextStyle _applyCustomAttributes( TextStyle textStyle, Map attributes) { - if (customStyleBuilder == null) { + if (widget.customStyleBuilder == null) { return textStyle; } attributes.keys.forEach((key) { final attr = attributes[key]; if (attr != null) { /// Custom Attribute - final customAttr = customStyleBuilder!.call(attr); + final customAttr = widget.customStyleBuilder!.call(attr); textStyle = textStyle.merge(customAttr); } }); return textStyle; } - TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { + TextSpan _getTextSpanFromNode( + DefaultStyles defaultStyles, Node node, Style lineStyle) { final textNode = node as leaf.Text; - final style = textNode.style; + final nodeStyle = textNode.style; + final isLink = nodeStyle.containsKey(Attribute.link.key) && + nodeStyle.attributes[Attribute.link.key]!.value != null; + + return TextSpan( + text: textNode.value, + style: _getInlineTextStyle( + textNode, defaultStyles, nodeStyle, lineStyle, isLink), + recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null, + mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null, + ); + } + + TextStyle _getInlineTextStyle(leaf.Text textNode, DefaultStyles defaultStyles, + Style nodeStyle, Style lineStyle, bool isLink) { var res = const TextStyle(); // This is inline text style final color = textNode.style.attributes[Attribute.color.key]; { Attribute.bold.key: defaultStyles.bold, Attribute.italic.key: defaultStyles.italic, + Attribute.small.key: defaultStyles.small, 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 (nodeStyle.values.any((v) => v.key == k)) { if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) { var textColor = defaultStyles.color; if (color?.value is String) { @@ -193,12 +309,19 @@ class TextLine extends StatelessWidget { } res = _merge(res.copyWith(decorationColor: textColor), s!.copyWith(decorationColor: textColor)); + } else if (k == Attribute.link.key && !isLink) { + // null value for link should be ignored + // i.e. nodeStyle.attributes[Attribute.link.key]!.value == null } else { res = _merge(res, s!); } } }); + if (nodeStyle.containsKey(Attribute.inlineCode.key)) { + res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle)); + } + final font = textNode.style.attributes[Attribute.font.key]; if (font != null && font.value != null) { res = res.merge(TextStyle(fontFamily: font.value)); @@ -217,7 +340,14 @@ class TextLine extends StatelessWidget { res = res.merge(defaultStyles.sizeHuge); break; default: - final fontSize = double.tryParse(size.value); + double? fontSize; + if (size.value is double) { + fontSize = size.value; + } else if (size.value is int) { + fontSize = size.value.toDouble(); + } else if (size.value is String) { + fontSize = double.tryParse(size.value); + } if (fontSize != null) { res = res.merge(TextStyle(fontSize: fontSize)); } else { @@ -243,7 +373,96 @@ class TextLine extends StatelessWidget { } res = _applyCustomAttributes(res, textNode.style.attributes); - return TextSpan(text: textNode.value, style: res); + return res; + } + + GestureRecognizer _getRecognizer(Node segment) { + if (_linkRecognizers.containsKey(segment)) { + return _linkRecognizers[segment]!; + } + + if (isDesktop || widget.readOnly) { + _linkRecognizers[segment] = TapGestureRecognizer() + ..onTap = () => _tapNodeLink(segment); + } else { + _linkRecognizers[segment] = LongPressGestureRecognizer() + ..onLongPress = () => _longPressLink(segment); + } + return _linkRecognizers[segment]!; + } + + Future _launchUrl(String url) async { + await launch(url); + } + + void _tapNodeLink(Node node) { + final link = node.style.attributes[Attribute.link.key]!.value; + + _tapLink(link); + } + + void _tapLink(String? link) { + if (!widget.readOnly || link == null) { + return; + } + + var launchUrl = widget.onLaunchUrl; + launchUrl ??= _launchUrl; + + link = link.trim(); + if (!linkPrefixes + .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { + link = 'https://$link'; + } + launchUrl(link); + } + + Future _longPressLink(Node node) async { + final link = node.style.attributes[Attribute.link.key]!.value!; + final action = await widget.linkActionPicker(node); + switch (action) { + case LinkMenuAction.launch: + _tapLink(link); + break; + case LinkMenuAction.copy: + // ignore: unawaited_futures + Clipboard.setData(ClipboardData(text: link)); + break; + case LinkMenuAction.remove: + final range = _getLinkRange(node); + widget.controller + .formatText(range.start, range.end - range.start, Attribute.link); + break; + case LinkMenuAction.none: + break; + } + } + + TextRange _getLinkRange(Node node) { + var start = node.documentOffset; + var length = node.length; + var prev = node.previous; + final linkAttr = node.style.attributes[Attribute.link.key]!; + while (prev != null) { + if (prev.style.attributes[Attribute.link.key] == linkAttr) { + start = prev.documentOffset; + length += prev.length; + prev = prev.previous; + } else { + break; + } + } + + var next = node.next; + while (next != null) { + if (next.style.attributes[Attribute.link.key] == linkAttr) { + length += next.length; + next = next.next; + } else { + break; + } + } + return TextRange(start: start, end: start + length); } TextStyle _merge(TextStyle a, TextStyle b) { @@ -296,6 +515,7 @@ class EditableTextLine extends RenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { + final defaultStyles = DefaultStyles.getInstance(context); return RenderEditableTextLine( line, textDirection, @@ -305,12 +525,14 @@ class EditableTextLine extends RenderObjectWidget { devicePixelRatio, _getPadding(), color, - cursorCont); + cursorCont, + defaultStyles.inlineCode!); } @override void updateRenderObject( BuildContext context, covariant RenderEditableTextLine renderObject) { + final defaultStyles = DefaultStyles.getInstance(context); renderObject ..setLine(line) ..setPadding(_getPadding()) @@ -320,7 +542,8 @@ class EditableTextLine extends RenderObjectWidget { ..setEnableInteractiveSelection(enableInteractiveSelection) ..hasFocus = hasFocus ..setDevicePixelRatio(devicePixelRatio) - ..setCursorCont(cursorCont); + ..setCursorCont(cursorCont) + ..setInlineCodeStyle(defaultStyles.inlineCode!); } EdgeInsetsGeometry _getPadding() { @@ -334,17 +557,18 @@ class EditableTextLine extends RenderObjectWidget { enum TextLineSlot { LEADING, BODY } class RenderEditableTextLine extends RenderEditableBox { + /// Creates new editable paragraph render box. RenderEditableTextLine( - this.line, - this.textDirection, - this.textSelection, - this.enableInteractiveSelection, - this.hasFocus, - this.devicePixelRatio, - this.padding, - this.color, - this.cursorCont, - ); + this.line, + this.textDirection, + this.textSelection, + this.enableInteractiveSelection, + this.hasFocus, + this.devicePixelRatio, + this.padding, + this.color, + this.cursorCont, + this.inlineCodeStyle); RenderBox? _leading; RenderContentProxyBox? _body; @@ -360,7 +584,8 @@ class RenderEditableTextLine extends RenderEditableBox { EdgeInsets? _resolvedPadding; bool? _containsCursor; List? _selectedRects; - Rect? _caretPrototype; + late Rect _caretPrototype; + InlineCodeStyle inlineCodeStyle; final Map children = {}; Iterable get _children sync* { @@ -404,7 +629,7 @@ class RenderEditableTextLine extends RenderEditableBox { color = c; if (containsTextSelection()) { - markNeedsPaint(); + safeMarkNeedsPaint(); } } @@ -414,9 +639,10 @@ class RenderEditableTextLine extends RenderEditableBox { } final containsSelection = containsTextSelection(); - if (attached && containsCursor()) { + if (_attachedToCursorController) { cursorCont.removeListener(markNeedsLayout); - cursorCont.color.removeListener(markNeedsPaint); + cursorCont.color.removeListener(safeMarkNeedsPaint); + _attachedToCursorController = false; } textSelection = t; @@ -424,11 +650,12 @@ class RenderEditableTextLine extends RenderEditableBox { _containsCursor = null; if (attached && containsCursor()) { cursorCont.addListener(markNeedsLayout); - cursorCont.color.addListener(markNeedsPaint); + cursorCont.color.addListener(safeMarkNeedsPaint); + _attachedToCursorController = true; } if (containsSelection || containsTextSelection()) { - markNeedsPaint(); + safeMarkNeedsPaint(); } } @@ -468,14 +695,25 @@ class RenderEditableTextLine extends RenderEditableBox { _body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?; } + void setInlineCodeStyle(InlineCodeStyle newStyle) { + if (inlineCodeStyle == newStyle) return; + inlineCodeStyle = newStyle; + markNeedsLayout(); + } + + // Start selection implementation + bool containsTextSelection() { return line.documentOffset <= textSelection.end && textSelection.start <= line.documentOffset + line.length - 1; } bool containsCursor() { - return _containsCursor ??= textSelection.isCollapsed && - line.containsOffset(textSelection.baseOffset); + return _containsCursor ??= cursorCont.isFloatingCursorActive + ? line + .containsOffset(cursorCont.floatingCursorTextPosition.value!.offset) + : textSelection.isCollapsed && + line.containsOffset(textSelection.baseOffset); } RenderBox? _updateChild( @@ -570,6 +808,9 @@ class RenderEditableTextLine extends RenderEditableBox { return _getPosition(position, 1.5); } + @override + bool get isRepaintBoundary => true; + TextPosition? _getPosition(TextPosition textPosition, double dyScale) { assert(textPosition.offset < line.length); final offset = getOffsetForCaret(textPosition) @@ -608,6 +849,17 @@ class RenderEditableTextLine extends RenderEditableBox { cursorCont.style.height ?? preferredLineHeight(const TextPosition(offset: 0)); + // TODO: This is no longer producing the highest-fidelity caret + // heights for Android, especially when non-alphabetic languages + // are involved. The current implementation overrides the height set + // here with the full measured height of the text on Android which looks + // superior (subjectively and in terms of fidelity) in _paintCaret. We + // should rework this properly to once again match the platform. The constant + // _kCaretHeightOffset scales poorly for small font sizes. + // + /// On iOS, the cursor is taller than the cursor on Android. The height + /// of the cursor for iOS is approximate and obtained through an eyeball + /// comparison. void _computeCaretPrototype() { switch (defaultTargetPlatform) { case TargetPlatform.iOS: @@ -625,15 +877,30 @@ class RenderEditableTextLine extends RenderEditableBox { } } + void _onFloatingCursorChange() { + _containsCursor = null; + markNeedsPaint(); + } + + // End caret implementation + + // + + // Start render box overrides + + bool _attachedToCursorController = false; + @override void attach(covariant PipelineOwner owner) { super.attach(owner); for (final child in _children) { child.attach(owner); } + cursorCont.floatingCursorTextPosition.addListener(_onFloatingCursorChange); if (containsCursor()) { cursorCont.addListener(markNeedsLayout); - cursorCont.color.addListener(markNeedsPaint); + cursorCont.color.addListener(safeMarkNeedsPaint); + _attachedToCursorController = true; } } @@ -643,9 +910,12 @@ class RenderEditableTextLine extends RenderEditableBox { for (final child in _children) { child.detach(); } - if (containsCursor()) { + cursorCont.floatingCursorTextPosition + .removeListener(_onFloatingCursorChange); + if (_attachedToCursorController) { cursorCont.removeListener(markNeedsLayout); - cursorCont.color.removeListener(markNeedsPaint); + cursorCont.color.removeListener(safeMarkNeedsPaint); + _attachedToCursorController = false; } } @@ -785,11 +1055,13 @@ class RenderEditableTextLine extends RenderEditableBox { } CursorPainter get _cursorPainter => CursorPainter( - _body, - cursorCont.style, - _caretPrototype!, - cursorCont.color.value, - devicePixelRatio, + editable: _body, + style: cursorCont.style, + prototype: _caretPrototype, + color: cursorCont.isFloatingCursorActive + ? cursorCont.style.backgroundColor + : cursorCont.color.value, + devicePixelRatio: devicePixelRatio, ); @override @@ -803,21 +1075,37 @@ class RenderEditableTextLine extends RenderEditableBox { 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 (inlineCodeStyle.backgroundColor != null) { + for (final item in line.children) { + if (item is! leaf.Text || + !item.style.containsKey(Attribute.inlineCode.key)) { + continue; + } + final textRange = TextSelection( + baseOffset: item.offset, extentOffset: item.offset + item.length); + final rects = _body!.getBoxesForSelection(textRange); + final paint = Paint()..color = inlineCodeStyle.backgroundColor!; + for (final box in rects) { + final rect = box.toRect().translate(0, 1).shift(effectiveOffset); + if (inlineCodeStyle.radius == null) { + final paintRect = Rect.fromLTRB( + rect.left - 2, rect.top, rect.right + 2, rect.bottom); + context.canvas.drawRect(paintRect, paint); + } else { + final paintRect = RRect.fromLTRBR(rect.left - 2, rect.top, + rect.right + 2, rect.bottom, inlineCodeStyle.radius!); + context.canvas.drawRRect(paintRect, paint); + } + } + } } if (hasFocus && cursorCont.show.value && containsCursor() && !cursorCont.style.paintAboveText) { - _paintCursor(context, effectiveOffset); + _paintCursor(context, effectiveOffset, line.hasEmbed); } context.paintChild(_body!, effectiveOffset); @@ -826,7 +1114,18 @@ class RenderEditableTextLine extends RenderEditableBox { cursorCont.show.value && containsCursor() && cursorCont.style.paintAboveText) { - _paintCursor(context, effectiveOffset); + _paintCursor(context, effectiveOffset, line.hasEmbed); + } + + // paint the selection on the top + 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); } } } @@ -839,17 +1138,41 @@ class RenderEditableTextLine extends RenderEditableBox { } } - void _paintCursor(PaintingContext context, Offset effectiveOffset) { - final position = TextPosition( - offset: textSelection.extentOffset - line.documentOffset, - affinity: textSelection.base.affinity, - ); - _cursorPainter.paint(context.canvas, effectiveOffset, position); + void _paintCursor( + PaintingContext context, Offset effectiveOffset, bool lineHasEmbed) { + final position = cursorCont.isFloatingCursorActive + ? TextPosition( + offset: cursorCont.floatingCursorTextPosition.value!.offset - + line.documentOffset, + affinity: cursorCont.floatingCursorTextPosition.value!.affinity) + : TextPosition( + offset: textSelection.extentOffset - line.documentOffset, + affinity: textSelection.base.affinity); + _cursorPainter.paint( + context.canvas, effectiveOffset, position, lineHasEmbed); } @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return _children.first.hitTest(result, position: position); + if (_leading != null) { + final childParentData = _leading!.parentData as BoxParentData; + final isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (result, transformed) { + assert(transformed == position - childParentData.offset); + return _leading!.hitTest(result, position: transformed); + }); + if (isHit) return true; + } + if (_body == null) return false; + final parentData = _body!.parentData as BoxParentData; + return result.addWithPaintOffset( + offset: parentData.offset, + position: position, + hitTest: (result, position) { + return _body!.hitTest(result, position: position); + }); } @override @@ -872,6 +1195,17 @@ class RenderEditableTextLine extends RenderEditableBox { affinity: position.affinity, ); } + + void safeMarkNeedsPaint() { + if (!attached) { + //Should not paint if it was unattached. + return; + } + markNeedsPaint(); + } + + @override + Rect getCaretPrototype(TextPosition position) => _caretPrototype; } class _TextLineElement extends RenderObjectElement { diff --git a/lib/src/widgets/text_selection.dart b/lib/src/widgets/text_selection.dart index f11a42c4..fd7e704a 100644 --- a/lib/src/widgets/text_selection.dart +++ b/lib/src/widgets/text_selection.dart @@ -1,11 +1,9 @@ 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'; @@ -21,6 +19,8 @@ TextSelection localSelection(Node node, TextSelection selection, fromParent) { extentOffset: math.min(selection.end - offset, node.length - 1)); } +/// The text position that a give selection handle manipulates. Dragging the +/// [start] handle always moves the [start]/[baseOffset] of the selection. enum _TextSelectionHandlePosition { START, END } /// internal use, used to get drag direction information @@ -58,22 +58,29 @@ class DragTextSelection extends TextSelection { } } +/// An object that manages a pair of text selection handles. +/// +/// The selection handles are displayed in the [Overlay] that most closely +/// encloses the given [BuildContext]. 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, + /// Creates an object that manages overlay entries for selection handles. + /// + /// The [context] must not be null and must have an [Overlay] as an ancestor. + EditorTextSelectionOverlay({ + required this.value, + required this.context, + required this.toolbarLayerLink, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.renderObject, + required this.debugRequiredFor, + required this.selectionCtrls, + required this.selectionDelegate, + required this.clipboardStatus, this.onSelectionHandleTapped, - this.clipboardStatus, - ) { + this.dragStartBehavior = DragStartBehavior.start, + this.handlesVisible = false, + }) { final overlay = Overlay.of(context, rootOverlay: true)!; _toolbarController = AnimationController( @@ -81,20 +88,94 @@ class EditorTextSelectionOverlay { } TextEditingValue value; + + /// Whether selection handles are visible. + /// + /// Set to false if you want to hide the handles. Use this property to show or + /// hide the handle without rebuilding them. + /// + /// If this method is called while the [SchedulerBinding.schedulerPhase] is + /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or + /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed + /// until the post-frame callbacks phase. Otherwise the update is done + /// synchronously. This means that it is safe to call during builds, but also + /// that if you do call this during a build, the UI will not update until the + /// next frame (i.e. many milliseconds later). + /// + /// Defaults to false. bool handlesVisible = false; + + /// The context in which the selection handles should appear. + /// + /// This context must have an [Overlay] as an ancestor because this object + /// will display the text selection handles in that [Overlay]. final BuildContext context; + + /// Debugging information for explaining why the [Overlay] is required. final Widget debugRequiredFor; + + /// The object supplied to the [CompositedTransformTarget] that wraps the text + /// field. final LayerLink toolbarLayerLink; + + /// The objects supplied to the [CompositedTransformTarget] that wraps the + /// location of start selection handle. final LayerLink startHandleLayerLink; + + /// The objects supplied to the [CompositedTransformTarget] that wraps the + /// location of end selection handle. final LayerLink endHandleLayerLink; + + /// The editable line in which the selected text is being displayed. final RenderEditor? renderObject; + + /// Builds text selection handles and toolbar. final TextSelectionControls selectionCtrls; + + /// The delegate for manipulating the current selection in the owning + /// text field. final TextSelectionDelegate selectionDelegate; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], handle drag behavior will + /// begin upon the detection of a drag gesture. If set to + /// [DragStartBehavior.down] it will begin when a down event is first + /// detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], + /// which gives an example for the different behaviors. final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.textSelection.onSelectionHandleTapped} + /// A callback that's invoked when a selection handle is tapped. + /// + /// Both regular taps and long presses invoke this callback, but a drag + /// gesture won't. + /// {@endtemplate} final VoidCallback? onSelectionHandleTapped; + + /// Maintains the status of the clipboard for determining if its contents can + /// be pasted or not. + /// + /// Useful because the actual value of the clipboard can only be checked + /// asynchronously (see [Clipboard.getData]). final ClipboardStatusNotifier clipboardStatus; late AnimationController _toolbarController; + + /// A pair of handles. If this is non-null, there are always 2, though the + /// second is hidden when the selection is collapsed. List? _handles; + + /// A copy/paste toolbar. OverlayEntry? toolbar; TextSelection get _selection => value.selection; @@ -106,6 +187,8 @@ class EditorTextSelectionOverlay { return; } handlesVisible = visible; + // If we are in build state, it will be too late to update visibility. + // We will need to schedule the build in next frame. if (SchedulerBinding.instance!.schedulerPhase == SchedulerPhase.persistentCallbacks) { SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); @@ -114,6 +197,7 @@ class EditorTextSelectionOverlay { } } + /// Destroys the handles by removing them from overlay. void hideHandles() { if (_handles == null) { return; @@ -123,6 +207,9 @@ class EditorTextSelectionOverlay { _handles = null; } + /// Hides the toolbar part of the overlay. + /// + /// To hide the whole overlay, see [hide]. void hideToolbar() { assert(toolbar != null); _toolbarController.stop(); @@ -130,6 +217,7 @@ class EditorTextSelectionOverlay { toolbar = null; } + /// Shows the toolbar by inserting it into the [context]'s overlay. void showToolbar() { assert(toolbar == null); toolbar = OverlayEntry(builder: _buildToolbar); @@ -161,6 +249,15 @@ class EditorTextSelectionOverlay { )); } + /// Updates the overlay after the selection has changed. + /// + /// If this method is called while the [SchedulerBinding.schedulerPhase] is + /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or + /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed + /// until the post-frame callbacks phase. Otherwise the update is done + /// synchronously. This means that it is safe to call during builds, but also + /// that if you do call this during a build, the UI will not update until the + /// next frame (i.e. many milliseconds later). void update(TextEditingValue newValue) { if (value == newValue) { return; @@ -210,6 +307,7 @@ class EditorTextSelectionOverlay { } Widget _buildToolbar(BuildContext context) { + // Find the horizontal midpoint, just above the selected text. final endpoints = renderObject!.getEndpointsForSelection(_selection); final editingRegion = Rect.fromPoints( @@ -260,6 +358,7 @@ class EditorTextSelectionOverlay { toolbar?.markNeedsBuild(); } + /// Hides the entire overlay including the toolbar and the handles. void hide() { if (_handles != null) { _handles![0].remove(); @@ -271,11 +370,13 @@ class EditorTextSelectionOverlay { } } + /// Final cleanup. void dispose() { hide(); _toolbarController.dispose(); } + /// Builds the handles by inserting them into the [context]'s overlay. void showHandles() { assert(_handles == null); _handles = [ @@ -290,8 +391,17 @@ class EditorTextSelectionOverlay { Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! .insertAll(_handles!); } + + /// Causes the overlay to update its rendering. + /// + /// This is intended to be called when the [renderObject] may have changed its + /// text metrics (e.g. because the text was scrolled). + void updateForScroll() { + markNeedsBuild(); + } } +/// This widget represents a single draggable text selection handle. class _TextSelectionHandleOverlay extends StatefulWidget { const _TextSelectionHandleOverlay({ required this.selection, @@ -407,6 +517,10 @@ class _TextSelectionHandleOverlayState break; } + if (newSelection.baseOffset >= newSelection.extentOffset) { + return; // don't allow order swapping. + } + widget.onSelectionHandleChanged(newSelection); } @@ -711,9 +825,12 @@ class _EditorTextSelectionGestureDetectorState Widget build(BuildContext context) { final gestures = {}; - gestures[TapGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), + // Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector + // can receive the same tap events that a selection handle placed visually + // on top of it also receives. + gestures[_TransparentTapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>( + () => _TransparentTapGestureRecognizer(debugOwner: this), (instance) { instance ..onTapDown = _handleTapDown @@ -728,7 +845,8 @@ class _EditorTextSelectionGestureDetectorState gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer( - debugOwner: this, kind: PointerDeviceKind.touch), + debugOwner: this, + supportedDevices: {PointerDeviceKind.touch}), (instance) { instance ..onLongPressStart = _handleLongPressStart @@ -744,7 +862,8 @@ class _EditorTextSelectionGestureDetectorState gestures[HorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => HorizontalDragGestureRecognizer( - debugOwner: this, kind: PointerDeviceKind.mouse), + debugOwner: this, + supportedDevices: {PointerDeviceKind.mouse}), (instance) { instance ..dragStartBehavior = DragStartBehavior.down @@ -776,3 +895,32 @@ class _EditorTextSelectionGestureDetectorState ); } } + +// A TapGestureRecognizer which allows other GestureRecognizers to win in the +// GestureArena. This means both _TransparentTapGestureRecognizer and other +// GestureRecognizers can handle the same event. +// +// This enables proper handling of events on both the selection handle and the +// underlying input, since there is significant overlap between the two given +// the handle's padded hit area. For example, the selection handle needs to +// handle single taps on itself, but double taps need to be handled by the +// underlying input. +class _TransparentTapGestureRecognizer extends TapGestureRecognizer { + _TransparentTapGestureRecognizer({ + Object? debugOwner, + }) : super(debugOwner: debugOwner); + + @override + void rejectGesture(int pointer) { + // Accept new gestures that another recognizer has already won. + // Specifically, this needs to accept taps on the text selection handle on + // behalf of the text field in order to handle double tap to select. It must + // not accept other gestures like longpresses and drags that end outside of + // the text field. + if (state == GestureRecognizerState.ready) { + acceptGesture(pointer); + } else { + super.rejectGesture(pointer); + } + } +} diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index dff2013c..68960fbf 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_widget.dart'; import '../models/documents/attribute.dart'; +import '../models/themes/quill_dialog_theme.dart'; +import '../models/themes/quill_icon_theme.dart'; import '../utils/media_pick_setting.dart'; import 'controller.dart'; import 'toolbar/arrow_indicated_button_list.dart'; @@ -14,6 +17,7 @@ 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_alignment_button.dart'; import 'toolbar/select_header_style_button.dart'; import 'toolbar/toggle_check_list_button.dart'; import 'toolbar/toggle_style_button.dart'; @@ -29,6 +33,7 @@ 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_alignment_button.dart'; export 'toolbar/select_header_style_button.dart'; export 'toolbar/toggle_check_list_button.dart'; export 'toolbar/toggle_style_button.dart'; @@ -53,23 +58,36 @@ const double kIconButtonFactor = 1.77; class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { const QuillToolbar({ required this.children, - this.toolBarHeight = 36, + this.toolbarHeight = 36, + this.toolbarIconAlignment = WrapAlignment.center, + this.toolbarSectionSpacing = 4, + this.multiRowsDisplay = true, this.color, this.filePickImpl, - this.multiRowsDisplay, + this.locale, Key? key, }) : super(key: key); factory QuillToolbar.basic({ required QuillController controller, double toolbarIconSize = kDefaultIconSize, + double toolbarSectionSpacing = 4, + WrapAlignment toolbarIconAlignment = WrapAlignment.center, + bool showDividers = true, bool showBoldButton = true, bool showItalicButton = true, + bool showSmallButton = false, bool showUnderLineButton = true, bool showStrikeThrough = true, + bool showInlineCode = true, bool showColorButton = true, bool showBackgroundColorButton = true, bool showClearFormat = true, + bool showAlignmentButtons = false, + bool showLeftAlignment = true, + bool showCenterAlignment = true, + bool showRightAlignment = true, + bool showJustifyAlignment = true, bool showHeaderStyle = true, bool showListNumbers = true, bool showListBullets = true, @@ -90,19 +108,42 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { FilePickImpl? filePickImpl, WebImagePickImpl? webImagePickImpl, WebVideoPickImpl? webVideoPickImpl, + + ///The theme to use for the icons in the toolbar, uses type [QuillIconTheme] + QuillIconTheme? iconTheme, + + ///The theme to use for the theming of the [LinkDialog()], + ///shown when embedding an image, for example + QuillDialogTheme? dialogTheme, + + ///The locale to use for the editor toolbar, defaults to system locale + ///Currently the supported locales are: + /// * Locale('en') + /// * Locale('de') + /// * Locale('fr') + /// * Locale('zh') + /// and more https://github.com/singerdmx/flutter-quill#translation-of-toolbar + Locale? locale, Key? key, }) { final isButtonGroupShown = [ showHistory || showBoldButton || showItalicButton || + showSmallButton || showUnderLineButton || showStrikeThrough || + showInlineCode || showColorButton || showBackgroundColorButton || showClearFormat || onImagePickCallback != null || onVideoPickCallback != null, + showAlignmentButtons, + showLeftAlignment, + showCenterAlignment, + showRightAlignment, + showJustifyAlignment, showHeaderStyle, showListNumbers || showListBullets || showListCheck || showCodeBlock, showQuote || showIndent, @@ -111,8 +152,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { return QuillToolbar( key: key, - toolBarHeight: toolbarIconSize * 2, + toolbarHeight: toolbarIconSize * 2, + toolbarSectionSpacing: toolbarSectionSpacing, + toolbarIconAlignment: toolbarIconAlignment, multiRowsDisplay: multiRowsDisplay, + locale: locale, children: [ if (showHistory) HistoryButton( @@ -120,6 +164,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconSize: toolbarIconSize, controller: controller, undo: true, + iconTheme: iconTheme, ), if (showHistory) HistoryButton( @@ -127,6 +172,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconSize: toolbarIconSize, controller: controller, undo: false, + iconTheme: iconTheme, ), if (showBoldButton) ToggleStyleButton( @@ -134,6 +180,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { icon: Icons.format_bold, iconSize: toolbarIconSize, controller: controller, + iconTheme: iconTheme, ), if (showItalicButton) ToggleStyleButton( @@ -141,6 +188,15 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { icon: Icons.format_italic, iconSize: toolbarIconSize, controller: controller, + iconTheme: iconTheme, + ), + if (showSmallButton) + ToggleStyleButton( + attribute: Attribute.small, + icon: Icons.format_size, + iconSize: toolbarIconSize, + controller: controller, + iconTheme: iconTheme, ), if (showUnderLineButton) ToggleStyleButton( @@ -148,6 +204,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { icon: Icons.format_underline, iconSize: toolbarIconSize, controller: controller, + iconTheme: iconTheme, ), if (showStrikeThrough) ToggleStyleButton( @@ -155,6 +212,15 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { icon: Icons.format_strikethrough, iconSize: toolbarIconSize, controller: controller, + iconTheme: iconTheme, + ), + if (showInlineCode) + ToggleStyleButton( + attribute: Attribute.inlineCode, + icon: Icons.code, + iconSize: toolbarIconSize, + controller: controller, + iconTheme: iconTheme, ), if (showColorButton) ColorButton( @@ -162,6 +228,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconSize: toolbarIconSize, controller: controller, background: false, + iconTheme: iconTheme, ), if (showBackgroundColorButton) ColorButton( @@ -169,12 +236,14 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconSize: toolbarIconSize, controller: controller, background: true, + iconTheme: iconTheme, ), if (showClearFormat) ClearFormatButton( icon: Icons.format_clear, iconSize: toolbarIconSize, controller: controller, + iconTheme: iconTheme, ), if (showImageButton) ImageButton( @@ -185,6 +254,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { filePickImpl: filePickImpl, webImagePickImpl: webImagePickImpl, mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, ), if (showVideoButton) VideoButton( @@ -195,37 +266,67 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { filePickImpl: filePickImpl, webVideoPickImpl: webImagePickImpl, mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, ), if ((onImagePickCallback != null || onVideoPickCallback != null) && showCameraButton) CameraButton( - icon: Icons.photo_camera, - iconSize: toolbarIconSize, - controller: controller, - onImagePickCallback: onImagePickCallback, - onVideoPickCallback: onVideoPickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl, - webVideoPickImpl: webVideoPickImpl), - if (isButtonGroupShown[0] && + icon: Icons.photo_camera, + iconSize: toolbarIconSize, + controller: controller, + onImagePickCallback: onImagePickCallback, + onVideoPickCallback: onVideoPickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + webVideoPickImpl: webVideoPickImpl, + iconTheme: iconTheme, + ), + if (showDividers && + isButtonGroupShown[0] && (isButtonGroupShown[1] || isButtonGroupShown[2] || isButtonGroupShown[3] || - isButtonGroupShown[4])) + isButtonGroupShown[4] || + isButtonGroupShown[5])) VerticalDivider( indent: 12, endIndent: 12, color: Colors.grey.shade400, ), - if (showHeaderStyle) - SelectHeaderStyleButton( + if (showAlignmentButtons) + SelectAlignmentButton( controller: controller, iconSize: toolbarIconSize, + iconTheme: iconTheme, + showLeftAlignment: showLeftAlignment, + showCenterAlignment: showCenterAlignment, + showRightAlignment: showRightAlignment, + showJustifyAlignment: showJustifyAlignment, ), - if (isButtonGroupShown[1] && + if (showDividers && + isButtonGroupShown[1] && (isButtonGroupShown[2] || isButtonGroupShown[3] || - isButtonGroupShown[4])) + isButtonGroupShown[4] || + isButtonGroupShown[5])) + VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, + ), + if (showHeaderStyle) + SelectHeaderStyleButton( + controller: controller, + iconSize: toolbarIconSize, + iconTheme: iconTheme, + ), + if (showDividers && + showHeaderStyle && + isButtonGroupShown[2] && + (isButtonGroupShown[3] || + isButtonGroupShown[4] || + isButtonGroupShown[5])) VerticalDivider( indent: 12, endIndent: 12, @@ -237,6 +338,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, icon: Icons.format_list_numbered, iconSize: toolbarIconSize, + iconTheme: iconTheme, ), if (showListBullets) ToggleStyleButton( @@ -244,6 +346,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, icon: Icons.format_list_bulleted, iconSize: toolbarIconSize, + iconTheme: iconTheme, ), if (showListCheck) ToggleCheckListButton( @@ -251,6 +354,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, icon: Icons.check_box, iconSize: toolbarIconSize, + iconTheme: iconTheme, ), if (showCodeBlock) ToggleStyleButton( @@ -258,9 +362,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, icon: Icons.code, iconSize: toolbarIconSize, + iconTheme: iconTheme, ), - if (isButtonGroupShown[2] && - (isButtonGroupShown[3] || isButtonGroupShown[4])) + if (showDividers && + isButtonGroupShown[3] && + (isButtonGroupShown[4] || isButtonGroupShown[5])) VerticalDivider( indent: 12, endIndent: 12, @@ -272,6 +378,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, icon: Icons.format_quote, iconSize: toolbarIconSize, + iconTheme: iconTheme, ), if (showIndent) IndentButton( @@ -279,6 +386,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconSize: toolbarIconSize, controller: controller, isIncrease: true, + iconTheme: iconTheme, ), if (showIndent) IndentButton( @@ -286,8 +394,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconSize: toolbarIconSize, controller: controller, isIncrease: false, + iconTheme: iconTheme, ), - if (isButtonGroupShown[3] && isButtonGroupShown[4]) + if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5]) VerticalDivider( indent: 12, endIndent: 12, @@ -297,20 +406,25 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { LinkStyleButton( controller: controller, iconSize: toolbarIconSize, + iconTheme: iconTheme, + dialogTheme: dialogTheme, ), if (showHorizontalRule) InsertEmbedButton( controller: controller, icon: Icons.horizontal_rule, iconSize: toolbarIconSize, + iconTheme: iconTheme, ), ], ); } final List children; - final double toolBarHeight; - final bool? multiRowsDisplay; + final double toolbarHeight; + final double toolbarSectionSpacing; + final WrapAlignment toolbarIconAlignment; + final bool multiRowsDisplay; /// The color of the toolbar. /// @@ -320,23 +434,35 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { final FilePickImpl? filePickImpl; + ///The locale to use for the editor toolbar, defaults to system locale + ///Currently the supported locales are: + /// * Locale('en') + /// * Locale('de') + /// * Locale('fr') + /// * Locale('zh', 'CN') + /// and more https://github.com/singerdmx/flutter-quill#translation-of-toolbar + final Locale? locale; + @override - Size get preferredSize => Size.fromHeight(toolBarHeight); + Size get preferredSize => Size.fromHeight(toolbarHeight); @override Widget build(BuildContext context) { - if (multiRowsDisplay ?? true) { - return Wrap( - alignment: WrapAlignment.center, - runSpacing: 4, - spacing: 4, - children: children, - ); - } - return Container( - constraints: BoxConstraints.tightFor(height: preferredSize.height), - color: color ?? Theme.of(context).canvasColor, - child: ArrowIndicatedButtonList(buttons: children), + return I18n( + initialLocale: locale, + child: multiRowsDisplay + ? Wrap( + alignment: toolbarIconAlignment, + runSpacing: 4, + spacing: toolbarSectionSpacing, + children: children, + ) + : Container( + constraints: + BoxConstraints.tightFor(height: preferredSize.height), + color: color ?? Theme.of(context).canvasColor, + child: ArrowIndicatedButtonList(buttons: children), + ), ); } } diff --git a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart index b17157d4..b3858e5d 100644 --- a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart +++ b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart @@ -60,6 +60,8 @@ class _ArrowIndicatedButtonListState extends State } void _handleScroll() { + if (!mounted) return; + setState(() { _showLeftArrow = _controller.position.minScrollExtent != _controller.position.pixels; diff --git a/lib/src/widgets/toolbar/camera_button.dart b/lib/src/widgets/toolbar/camera_button.dart index 9c4f2d0d..96776c39 100644 --- a/lib/src/widgets/toolbar/camera_button.dart +++ b/lib/src/widgets/toolbar/camera_button.dart @@ -1,11 +1,10 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../toolbar.dart'; import 'image_video_utils.dart'; -import 'quill_icon_button.dart'; class CameraButton extends StatelessWidget { const CameraButton({ @@ -18,6 +17,7 @@ class CameraButton extends StatelessWidget { this.filePickImpl, this.webImagePickImpl, this.webVideoPickImpl, + this.iconTheme, Key? key, }) : super(key: key); @@ -38,16 +38,22 @@ class CameraButton extends StatelessWidget { final FilePickImpl? filePickImpl; + final QuillIconTheme? iconTheme; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final iconFillColor = + iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor); + return QuillIconButton( - icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), + icon: Icon(icon, size: iconSize, color: iconColor), highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, - fillColor: fillColor ?? theme.canvasColor, + fillColor: iconFillColor, onPressed: () => _handleCameraButtonTap(context, controller, onImagePickCallback: onImagePickCallback, onVideoPickCallback: onVideoPickCallback, diff --git a/lib/src/widgets/toolbar/clear_format_button.dart b/lib/src/widgets/toolbar/clear_format_button.dart index 67f1e8d2..652b783f 100644 --- a/lib/src/widgets/toolbar/clear_format_button.dart +++ b/lib/src/widgets/toolbar/clear_format_button.dart @@ -1,13 +1,13 @@ 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, + this.iconTheme, Key? key, }) : super(key: key); @@ -16,6 +16,8 @@ class ClearFormatButton extends StatefulWidget { final QuillController controller; + final QuillIconTheme? iconTheme; + @override _ClearFormatButtonState createState() => _ClearFormatButtonState(); } @@ -24,8 +26,10 @@ class _ClearFormatButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final iconColor = theme.iconTheme.color; - final fillColor = theme.canvasColor; + final iconColor = + widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final fillColor = + widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; return QuillIconButton( highlightElevation: 0, hoverElevation: 0, diff --git a/lib/src/widgets/toolbar/color_button.dart b/lib/src/widgets/toolbar/color_button.dart index fa757e8a..d6c90731 100644 --- a/lib/src/widgets/toolbar/color_button.dart +++ b/lib/src/widgets/toolbar/color_button.dart @@ -3,10 +3,11 @@ import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../../translations/toolbar.i18n.dart'; import '../../utils/color.dart'; import '../controller.dart'; import '../toolbar.dart'; -import 'quill_icon_button.dart'; /// Controls color styles. /// @@ -18,6 +19,7 @@ class ColorButton extends StatefulWidget { required this.controller, required this.background, this.iconSize = kDefaultIconSize, + this.iconTheme, Key? key, }) : super(key: key); @@ -25,6 +27,7 @@ class ColorButton extends StatefulWidget { final double iconSize; final bool background; final QuillController controller; + final QuillIconTheme? iconTheme; @override _ColorButtonState createState() => _ColorButtonState(); @@ -98,20 +101,20 @@ class _ColorButtonState extends State { final theme = Theme.of(context); final iconColor = _isToggledColor && !widget.background && !_isWhite ? stringToColor(_selectionStyle.attributes['color']!.value) - : theme.iconTheme.color; + : (widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color); final iconColorBackground = _isToggledBackground && widget.background && !_isWhitebackground ? stringToColor(_selectionStyle.attributes['background']!.value) - : theme.iconTheme.color; + : (widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color); final fillColor = _isToggledColor && !widget.background && _isWhite ? stringToColor('#ffffff') - : theme.canvasColor; + : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); final fillColorBackground = _isToggledBackground && widget.background && _isWhitebackground ? stringToColor('#ffffff') - : theme.canvasColor; + : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); return QuillIconButton( highlightElevation: 0, @@ -140,7 +143,7 @@ class _ColorButtonState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Select Color'), + title: Text('Select Color'.i18n), backgroundColor: Theme.of(context).canvasColor, content: SingleChildScrollView( child: MaterialPicker( diff --git a/lib/src/widgets/toolbar/history_button.dart b/lib/src/widgets/toolbar/history_button.dart index 2ed794c5..a245c2e8 100644 --- a/lib/src/widgets/toolbar/history_button.dart +++ b/lib/src/widgets/toolbar/history_button.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../../../flutter_quill.dart'; -import 'quill_icon_button.dart'; class HistoryButton extends StatefulWidget { const HistoryButton({ @@ -9,6 +8,7 @@ class HistoryButton extends StatefulWidget { required this.controller, required this.undo, this.iconSize = kDefaultIconSize, + this.iconTheme, Key? key, }) : super(key: key); @@ -16,6 +16,7 @@ class HistoryButton extends StatefulWidget { final double iconSize; final bool undo; final QuillController controller; + final QuillIconTheme? iconTheme; @override _HistoryButtonState createState() => _HistoryButtonState(); @@ -30,7 +31,8 @@ class _HistoryButtonState extends State { theme = Theme.of(context); _setIconColor(); - final fillColor = theme.canvasColor; + final fillColor = + widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; widget.controller.changes.listen((event) async { _setIconColor(); }); @@ -50,14 +52,14 @@ class _HistoryButtonState extends State { if (widget.undo) { setState(() { _iconColor = widget.controller.hasUndo - ? theme.iconTheme.color - : theme.disabledColor; + ? widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color + : widget.iconTheme?.disabledIconColor ?? theme.disabledColor; }); } else { setState(() { _iconColor = widget.controller.hasRedo - ? theme.iconTheme.color - : theme.disabledColor; + ? widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color + : widget.iconTheme?.disabledIconColor ?? theme.disabledColor; }); } } diff --git a/lib/src/widgets/toolbar/image_button.dart b/lib/src/widgets/toolbar/image_button.dart index d30078f0..2d42c192 100644 --- a/lib/src/widgets/toolbar/image_button.dart +++ b/lib/src/widgets/toolbar/image_button.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../../models/documents/nodes/embed.dart'; -import '../../utils/media_pick_setting.dart'; +import '../../models/themes/quill_dialog_theme.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../link_dialog.dart'; import '../toolbar.dart'; import 'image_video_utils.dart'; -import 'quill_icon_button.dart'; class ImageButton extends StatelessWidget { const ImageButton({ @@ -19,6 +19,8 @@ class ImageButton extends StatelessWidget { this.filePickImpl, this.webImagePickImpl, this.mediaPickSettingSelector, + this.iconTheme, + this.dialogTheme, Key? key, }) : super(key: key); @@ -37,16 +39,24 @@ class ImageButton extends StatelessWidget { final MediaPickSettingSelector? mediaPickSettingSelector; + final QuillIconTheme? iconTheme; + + final QuillDialogTheme? dialogTheme; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final iconFillColor = + iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor); + return QuillIconButton( - icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), + icon: Icon(icon, size: iconSize, color: iconColor), highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, - fillColor: fillColor ?? theme.canvasColor, + fillColor: iconFillColor, onPressed: () => _onPressedHandler(context), ); } @@ -80,7 +90,7 @@ class ImageButton extends StatelessWidget { void _typeLink(BuildContext context) { showDialog( context: context, - builder: (_) => const LinkDialog(), + builder: (_) => LinkDialog(dialogTheme: dialogTheme), ).then(_linkSubmitted); } diff --git a/lib/src/widgets/toolbar/image_video_utils.dart b/lib/src/widgets/toolbar/image_video_utils.dart index 0305378b..f2814ec9 100644 --- a/lib/src/widgets/toolbar/image_video_utils.dart +++ b/lib/src/widgets/toolbar/image_video_utils.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../../models/documents/nodes/embed.dart'; -import '../../utils/media_pick_setting.dart'; +import '../../translations/toolbar.i18n.dart'; import '../controller.dart'; import '../toolbar.dart'; @@ -26,7 +26,7 @@ class ImageVideoUtils { Icons.collections, color: Colors.orangeAccent, ), - label: const Text('Gallery'), + label: Text('Gallery'.i18n), onPressed: () => Navigator.pop(ctx, MediaPickSetting.Gallery), ), TextButton.icon( @@ -34,7 +34,7 @@ class ImageVideoUtils { Icons.link, color: Colors.cyanAccent, ), - label: const Text('Link'), + label: Text('Link'.i18n), onPressed: () => Navigator.pop(ctx, MediaPickSetting.Link), ) ], diff --git a/lib/src/widgets/toolbar/indent_button.dart b/lib/src/widgets/toolbar/indent_button.dart index aa6dfadb..476ebc46 100644 --- a/lib/src/widgets/toolbar/indent_button.dart +++ b/lib/src/widgets/toolbar/indent_button.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../../../flutter_quill.dart'; -import 'quill_icon_button.dart'; class IndentButton extends StatefulWidget { const IndentButton({ @@ -9,6 +8,7 @@ class IndentButton extends StatefulWidget { required this.controller, required this.isIncrease, this.iconSize = kDefaultIconSize, + this.iconTheme, Key? key, }) : super(key: key); @@ -17,6 +17,8 @@ class IndentButton extends StatefulWidget { final QuillController controller; final bool isIncrease; + final QuillIconTheme? iconTheme; + @override _IndentButtonState createState() => _IndentButtonState(); } @@ -25,14 +27,17 @@ class _IndentButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final iconColor = theme.iconTheme.color; - final fillColor = theme.canvasColor; + + final iconColor = + widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final iconFillColor = + widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; return QuillIconButton( highlightElevation: 0, hoverElevation: 0, size: widget.iconSize * 1.77, icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), - fillColor: fillColor, + fillColor: iconFillColor, onPressed: () { final indent = widget.controller .getSelectionStyle() diff --git a/lib/src/widgets/toolbar/insert_embed_button.dart b/lib/src/widgets/toolbar/insert_embed_button.dart index 5c889b69..45a4d5ba 100644 --- a/lib/src/widgets/toolbar/insert_embed_button.dart +++ b/lib/src/widgets/toolbar/insert_embed_button.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import '../../models/documents/nodes/embed.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../toolbar.dart'; -import 'quill_icon_button.dart'; class InsertEmbedButton extends StatelessWidget { const InsertEmbedButton({ @@ -11,6 +11,7 @@ class InsertEmbedButton extends StatelessWidget { required this.icon, this.iconSize = kDefaultIconSize, this.fillColor, + this.iconTheme, Key? key, }) : super(key: key); @@ -18,9 +19,16 @@ class InsertEmbedButton extends StatelessWidget { final IconData icon; final double iconSize; final Color? fillColor; + final QuillIconTheme? iconTheme; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + + final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final iconFillColor = + iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor); + return QuillIconButton( highlightElevation: 0, hoverElevation: 0, @@ -28,9 +36,9 @@ class InsertEmbedButton extends StatelessWidget { icon: Icon( icon, size: iconSize, - color: Theme.of(context).iconTheme.color, + color: iconColor, ), - fillColor: fillColor ?? Theme.of(context).canvasColor, + fillColor: iconFillColor, onPressed: () { final index = controller.selection.baseOffset; final length = controller.selection.extentOffset - index; diff --git a/lib/src/widgets/toolbar/link_style_button.dart b/lib/src/widgets/toolbar/link_style_button.dart index 32210504..35fca8a6 100644 --- a/lib/src/widgets/toolbar/link_style_button.dart +++ b/lib/src/widgets/toolbar/link_style_button.dart @@ -1,22 +1,28 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; +import '../../models/themes/quill_dialog_theme.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../../translations/toolbar.i18n.dart'; import '../controller.dart'; import '../link_dialog.dart'; import '../toolbar.dart'; -import 'quill_icon_button.dart'; class LinkStyleButton extends StatefulWidget { const LinkStyleButton({ required this.controller, this.iconSize = kDefaultIconSize, this.icon, + this.iconTheme, + this.dialogTheme, Key? key, }) : super(key: key); final QuillController controller; final IconData? icon; final double iconSize; + final QuillIconTheme? iconTheme; + final QuillDialogTheme? dialogTheme; @override _LinkStyleButtonState createState() => _LinkStyleButtonState(); @@ -48,22 +54,44 @@ class _LinkStyleButtonState extends State { widget.controller.removeListener(_didChangeSelection); } + final GlobalKey _toolTipKey = GlobalKey(); + @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, + return GestureDetector( + onTap: () async { + final dynamic tooltip = _toolTipKey.currentState; + tooltip.ensureTooltipVisible(); + Future.delayed( + const Duration( + seconds: 3, + ), + tooltip.deactivate, + ); + }, + child: Tooltip( + key: _toolTipKey, + message: 'Please first select some text to transform into a link.'.i18n, + child: QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * kIconButtonFactor, + icon: Icon( + widget.icon ?? Icons.link, + size: widget.iconSize, + color: isEnabled + ? (widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color) + : (widget.iconTheme?.disabledIconColor ?? theme.disabledColor), + ), + fillColor: + widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor, + onPressed: pressedHandler, + ), ), - fillColor: Theme.of(context).canvasColor, - onPressed: pressedHandler, ); } @@ -71,7 +99,7 @@ class _LinkStyleButtonState extends State { showDialog( context: context, builder: (ctx) { - return const LinkDialog(); + return LinkDialog(dialogTheme: widget.dialogTheme); }, ).then(_linkSubmitted); } diff --git a/lib/src/widgets/toolbar/select_alignment_button.dart b/lib/src/widgets/toolbar/select_alignment_button.dart new file mode 100644 index 00000000..5ded1021 --- /dev/null +++ b/lib/src/widgets/toolbar/select_alignment_button.dart @@ -0,0 +1,154 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/style.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../controller.dart'; +import '../toolbar.dart'; + +class SelectAlignmentButton extends StatefulWidget { + const SelectAlignmentButton({ + required this.controller, + this.iconSize = kDefaultIconSize, + this.iconTheme, + this.showLeftAlignment, + this.showCenterAlignment, + this.showRightAlignment, + this.showJustifyAlignment, + Key? key, + }) : super(key: key); + + final QuillController controller; + final double iconSize; + + final QuillIconTheme? iconTheme; + final bool? showLeftAlignment; + final bool? showCenterAlignment; + final bool? showRightAlignment; + final bool? showJustifyAlignment; + + @override + _SelectAlignmentButtonState createState() => _SelectAlignmentButtonState(); +} + +class _SelectAlignmentButtonState extends State { + Attribute? _value; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + @override + void initState() { + super.initState(); + setState(() { + _value = _selectionStyle.attributes[Attribute.align.key] ?? + Attribute.leftAlignment; + }); + widget.controller.addListener(_didChangeEditingValue); + } + + @override + Widget build(BuildContext context) { + final _valueToText = { + if (widget.showLeftAlignment!) + Attribute.leftAlignment: Attribute.leftAlignment.value!, + if (widget.showCenterAlignment!) + Attribute.centerAlignment: Attribute.centerAlignment.value!, + if (widget.showRightAlignment!) + Attribute.rightAlignment: Attribute.rightAlignment.value!, + if (widget.showJustifyAlignment!) + Attribute.justifyAlignment: Attribute.justifyAlignment.value!, + }; + + final _valueAttribute = [ + if (widget.showLeftAlignment!) Attribute.leftAlignment, + if (widget.showCenterAlignment!) Attribute.centerAlignment, + if (widget.showRightAlignment!) Attribute.rightAlignment, + if (widget.showJustifyAlignment!) Attribute.justifyAlignment + ]; + final _valueString = [ + if (widget.showLeftAlignment!) Attribute.leftAlignment.value!, + if (widget.showCenterAlignment!) Attribute.centerAlignment.value!, + if (widget.showRightAlignment!) Attribute.rightAlignment.value!, + if (widget.showJustifyAlignment!) Attribute.justifyAlignment.value!, + ]; + + final theme = Theme.of(context); + + final buttonCount = ((widget.showLeftAlignment!) ? 1 : 0) + + ((widget.showCenterAlignment!) ? 1 : 0) + + ((widget.showRightAlignment!) ? 1 : 0) + + ((widget.showJustifyAlignment!) ? 1 : 0); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(buttonCount, (index) { + return Padding( + padding: 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] + ? (widget.iconTheme?.iconSelectedFillColor ?? + theme.toggleableActiveColor) + : (widget.iconTheme?.iconUnselectedFillColor ?? + theme.canvasColor), + onPressed: () => _valueAttribute[index] == Attribute.leftAlignment + ? widget.controller + .formatSelection(Attribute.clone(Attribute.align, null)) + : widget.controller.formatSelection(_valueAttribute[index]), + child: Icon( + _valueString[index] == Attribute.leftAlignment.value + ? Icons.format_align_left + : _valueString[index] == Attribute.centerAlignment.value + ? Icons.format_align_center + : _valueString[index] == Attribute.rightAlignment.value + ? Icons.format_align_right + : Icons.format_align_justify, + size: widget.iconSize, + color: _valueToText[_value] == _valueString[index] + ? (widget.iconTheme?.iconSelectedColor ?? + theme.primaryIconTheme.color) + : (widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color), + ), + ), + ), + ); + }), + ); + } + + void _didChangeEditingValue() { + setState(() { + _value = _selectionStyle.attributes[Attribute.align.key] ?? + Attribute.leftAlignment; + }); + } + + @override + void didUpdateWidget(covariant SelectAlignmentButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _value = _selectionStyle.attributes[Attribute.align.key] ?? + Attribute.leftAlignment; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } +} diff --git a/lib/src/widgets/toolbar/select_header_style_button.dart b/lib/src/widgets/toolbar/select_header_style_button.dart index 715e3632..3244b31d 100644 --- a/lib/src/widgets/toolbar/select_header_style_button.dart +++ b/lib/src/widgets/toolbar/select_header_style_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../toolbar.dart'; @@ -10,12 +11,15 @@ class SelectHeaderStyleButton extends StatefulWidget { const SelectHeaderStyleButton({ required this.controller, this.iconSize = kDefaultIconSize, + this.iconTheme, Key? key, }) : super(key: key); final QuillController controller; final double iconSize; + final QuillIconTheme? iconTheme; + @override _SelectHeaderStyleButtonState createState() => _SelectHeaderStyleButtonState(); @@ -63,7 +67,7 @@ class _SelectHeaderStyleButtonState extends State { mainAxisSize: MainAxisSize.min, children: List.generate(4, (index) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), child: ConstrainedBox( constraints: BoxConstraints.tightFor( width: widget.iconSize * kIconButtonFactor, @@ -77,16 +81,20 @@ class _SelectHeaderStyleButtonState extends State { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(2)), fillColor: _valueToText[_value] == _valueString[index] - ? theme.toggleableActiveColor - : theme.canvasColor, + ? (widget.iconTheme?.iconSelectedFillColor ?? + theme.toggleableActiveColor) + : (widget.iconTheme?.iconUnselectedFillColor ?? + 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, + ? (widget.iconTheme?.iconSelectedColor ?? + theme.primaryIconTheme.color) + : (widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color), ), ), ), diff --git a/lib/src/widgets/toolbar/toggle_check_list_button.dart b/lib/src/widgets/toolbar/toggle_check_list_button.dart index 861da445..ed24a84d 100644 --- a/lib/src/widgets/toolbar/toggle_check_list_button.dart +++ b/lib/src/widgets/toolbar/toggle_check_list_button.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../toolbar.dart'; -import 'toggle_style_button.dart'; class ToggleCheckListButton extends StatefulWidget { const ToggleCheckListButton({ @@ -14,6 +14,7 @@ class ToggleCheckListButton extends StatefulWidget { this.iconSize = kDefaultIconSize, this.fillColor, this.childBuilder = defaultToggleStyleButtonBuilder, + this.iconTheme, Key? key, }) : super(key: key); @@ -28,6 +29,8 @@ class ToggleCheckListButton extends StatefulWidget { final Attribute attribute; + final QuillIconTheme? iconTheme; + @override _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); } @@ -81,18 +84,15 @@ class _ToggleCheckListButtonState extends State { @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, + _toggleAttribute, widget.iconSize, + widget.iconTheme, ); } diff --git a/lib/src/widgets/toolbar/toggle_style_button.dart b/lib/src/widgets/toolbar/toggle_style_button.dart index 624a31f9..e4a680fe 100644 --- a/lib/src/widgets/toolbar/toggle_style_button.dart +++ b/lib/src/widgets/toolbar/toggle_style_button.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../toolbar.dart'; -import 'quill_icon_button.dart'; typedef ToggleStyleButtonBuilder = Widget Function( BuildContext context, @@ -14,6 +14,7 @@ typedef ToggleStyleButtonBuilder = Widget Function( bool? isToggled, VoidCallback? onPressed, [ double iconSize, + QuillIconTheme? iconTheme, ]); class ToggleStyleButton extends StatefulWidget { @@ -24,6 +25,7 @@ class ToggleStyleButton extends StatefulWidget { this.iconSize = kDefaultIconSize, this.fillColor, this.childBuilder = defaultToggleStyleButtonBuilder, + this.iconTheme, Key? key, }) : super(key: key); @@ -38,6 +40,9 @@ class ToggleStyleButton extends StatefulWidget { final ToggleStyleButtonBuilder childBuilder; + ///Specify an icon theme for the icons in the toolbar + final QuillIconTheme? iconTheme; + @override _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); } @@ -56,18 +61,15 @@ class _ToggleStyleButtonState extends State { @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, + _toggleAttribute, widget.iconSize, + widget.iconTheme, ); } @@ -117,17 +119,25 @@ Widget defaultToggleStyleButtonBuilder( bool? isToggled, VoidCallback? onPressed, [ double iconSize = kDefaultIconSize, + QuillIconTheme? iconTheme, ]) { 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; + ? (iconTheme?.iconSelectedColor ?? + theme + .primaryIconTheme.color) //You can specify your own icon color + : (iconTheme?.iconUnselectedColor ?? theme.iconTheme.color) + : (iconTheme?.disabledIconColor ?? theme.disabledColor); + final fill = isEnabled + ? isToggled == true + ? (iconTheme?.iconSelectedFillColor ?? + theme.toggleableActiveColor) //Selected icon fill color + : (iconTheme?.iconUnselectedFillColor ?? + theme.canvasColor) //Unselected icon fill color : + : (iconTheme?.disabledIconFillColor ?? + (fillColor ?? theme.canvasColor)); //Disabled icon fill color return QuillIconButton( highlightElevation: 0, hoverElevation: 0, diff --git a/lib/src/widgets/toolbar/video_button.dart b/lib/src/widgets/toolbar/video_button.dart index 8f2c797d..dbda51d3 100644 --- a/lib/src/widgets/toolbar/video_button.dart +++ b/lib/src/widgets/toolbar/video_button.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../../models/documents/nodes/embed.dart'; -import '../../utils/media_pick_setting.dart'; +import '../../models/themes/quill_dialog_theme.dart'; +import '../../models/themes/quill_icon_theme.dart'; import '../controller.dart'; import '../link_dialog.dart'; import '../toolbar.dart'; import 'image_video_utils.dart'; -import 'quill_icon_button.dart'; class VideoButton extends StatelessWidget { const VideoButton({ @@ -19,6 +19,8 @@ class VideoButton extends StatelessWidget { this.filePickImpl, this.webVideoPickImpl, this.mediaPickSettingSelector, + this.iconTheme, + this.dialogTheme, Key? key, }) : super(key: key); @@ -37,16 +39,24 @@ class VideoButton extends StatelessWidget { final MediaPickSettingSelector? mediaPickSettingSelector; + final QuillIconTheme? iconTheme; + + final QuillDialogTheme? dialogTheme; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final iconFillColor = + iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor); + return QuillIconButton( - icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), + icon: Icon(icon, size: iconSize, color: iconColor), highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, - fillColor: fillColor ?? theme.canvasColor, + fillColor: iconFillColor, onPressed: () => _onPressedHandler(context), ); } @@ -80,7 +90,7 @@ class VideoButton extends StatelessWidget { void _typeLink(BuildContext context) { showDialog( context: context, - builder: (_) => const LinkDialog(), + builder: (_) => LinkDialog(dialogTheme: dialogTheme), ).then(_linkSubmitted); } diff --git a/lib/src/widgets/video_app.dart b/lib/src/widgets/video_app.dart index 44753a72..b0967451 100644 --- a/lib/src/widgets/video_app.dart +++ b/lib/src/widgets/video_app.dart @@ -35,13 +35,16 @@ class _VideoAppState extends State { // Ensure the first frame is shown after the video is initialized, // even before the play button has been pressed. setState(() {}); + }).catchError((error) { + setState(() {}); }); + ; } @override Widget build(BuildContext context) { final defaultStyles = DefaultStyles.getInstance(context); - if (!_controller.value.isInitialized || _controller.value.hasError) { + if (_controller.value.hasError) { if (widget.readOnly) { return RichText( text: TextSpan( @@ -54,6 +57,12 @@ class _VideoAppState extends State { return RichText( text: TextSpan(text: widget.videoUrl, style: defaultStyles.link)); + } else if (!_controller.value.isInitialized) { + return VideoProgressIndicator( + _controller, + allowScrubbing: true, + colors: const VideoProgressColors(playedColor: Colors.blue), + ); } return Container( diff --git a/lib/src/widgets/youtube_video_app.dart b/lib/src/widgets/youtube_video_app.dart index eae3cb51..8f52f3a7 100644 --- a/lib/src/widgets/youtube_video_app.dart +++ b/lib/src/widgets/youtube_video_app.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + import '../../flutter_quill.dart'; class YoutubeVideoApp extends StatefulWidget { diff --git a/lib/widgets/keyboard_listener.dart b/lib/widgets/keyboard_listener.dart deleted file mode 100644 index 20c72b74..00000000 --- a/lib/widgets/keyboard_listener.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/keyboard_listener.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 deleted file mode 100644 index ab326cec..00000000 --- a/lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.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/raw_editor/raw_editor_state_keyboard_mixin.dart'; diff --git a/lib/widgets/simple_viewer.dart b/lib/widgets/simple_viewer.dart deleted file mode 100644 index fbea8404..00000000 --- a/lib/widgets/simple_viewer.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/simple_viewer.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index de523739..93a80408 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,22 +1,22 @@ name: flutter_quill description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 1.9.4 +version: 3.0.4 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill environment: sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" + flutter: ">=2.5.3" dependencies: flutter: sdk: flutter collection: ^1.15.0 - flutter_colorpicker: ^0.4.0 + flutter_colorpicker: ^1.0.3 flutter_keyboard_visibility: ^5.0.0 image_picker: ^0.8.2 - photo_view: ^0.12.0 + photo_view: ^0.13.0 quiver: ^3.0.0 string_validator: ^0.3.0 tuple: ^2.0.0 @@ -30,6 +30,7 @@ dependencies: characters: ^1.1.0 youtube_player_flutter: ^8.0.0 diff_match_patch: ^0.4.1 + i18n_extension: ^4.1.3 dev_dependencies: flutter_test: