diff --git a/.gitignore b/.gitignore index e73faed9..0d11db7b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,10 +66,11 @@ build/ **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* +example/ios/Podfile.lock # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 -pubspec.lock \ No newline at end of file +pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index e578affd..2cdeaab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +# [4.0.5] +* Fixed casting null to Tuple2 when link dialog is dismissed without any input (e.g. barrier dismissed). + +# [4.0.4] +* Bug fix for text direction rtl. + +# [4.0.3] +* Support text direction rtl. + +# [4.0.2] +* Clear toggled style on selection change. + +# [4.0.1] +* Fix copy/cut/paste/selectAll not working. + +# [4.0.0] +* Upgrade for Flutter 2.10. + +# [3.9.11] +* Added Indonesian translation. + +# [3.9.10] +* Fix for undoing a modification ending with an indented line. + +# [3.9.9] +* iOS: Save image whose filename does not end with image file extension. + +# [3.9.8] +* Added Urdu translation. + +# [3.9.7] +* Fix for clicking on the Link button without any text on a new line crashes. + # [3.9.6] * Apply locale to QuillEditor(contents). diff --git a/README.md b/README.md index 5c1520cf..3c9a31f0 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,13 @@ Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follo } ``` -## Translation of toolbar -The package offers translations for the quill toolbar, it will follow the system locale unless you set your own locale with: +## Translation +The package offers translations for the quill toolbar and editor, it will follow the system locale unless you set your own locale with: ``` QuillToolbar(locale: Locale('fr'), ...) QuillEditor(locale: Locale('fr'), ...) ``` -Currently, translations are available for these locales: +Currently, translations are available for these 17 locales: * `Locale('en')` * `Locale('ar')` * `Locale('de')` @@ -128,9 +128,12 @@ Currently, translations are available for these locales: * `Locale('es')` * `Locale('tr')` * `Locale('uk')` +* `Locale('ur')` * `Locale('pt')` * `Locale('pl')` * `Locale('vi')` +* `Locale('id')` +* `Locale('no')` ### 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/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index eb73b2b0..1cce8762 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ @@ -14,6 +14,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D290BBC2BCE42906E260DD85 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC245C6D0FF6BF1D0B733C5B /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -32,7 +33,10 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2F435AE316A2CEF9DB2FCB44 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 45FB9682812B691627A14497 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 48A1E3B04AC3F3F79EE01940 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -44,6 +48,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC245C6D0FF6BF1D0B733C5B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,12 +56,24 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D290BBC2BCE42906E260DD85 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 54D582D77359D2F1CFEABAD6 /* Pods */ = { + isa = PBXGroup; + children = ( + 2F435AE316A2CEF9DB2FCB44 /* Pods-Runner.debug.xcconfig */, + 48A1E3B04AC3F3F79EE01940 /* Pods-Runner.release.xcconfig */, + 45FB9682812B691627A14497 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -74,7 +91,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, + 54D582D77359D2F1CFEABAD6 /* Pods */, + 9B5485B940DE97CC1A7B761C /* Frameworks */, ); sourceTree = ""; }; @@ -110,6 +128,14 @@ name = "Supporting Files"; sourceTree = ""; }; + 9B5485B940DE97CC1A7B761C /* Frameworks */ = { + isa = PBXGroup; + children = ( + DC245C6D0FF6BF1D0B733C5B /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -117,12 +143,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + C781F89B47F2E7DB7E2B4898 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 869AA06D856BCA582968F111 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -139,7 +167,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -194,6 +222,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 869AA06D856BCA582968F111 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -208,6 +253,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + C781F89B47F2E7DB7E2B4898 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -305,7 +372,10 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -433,7 +503,10 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -456,7 +529,10 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16..919434a6 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140cf..3db53b6e 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ + + diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 92772e9f..4f788487 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" #include diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h index 9846246b..dc139d85 100644 --- a/example/windows/flutter/generated_plugin_registrant.h +++ b/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index ed848d79..13b6b482 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -32,6 +32,7 @@ class Attribute { Attribute.placeholder.key: Attribute.placeholder, Attribute.header.key: Attribute.header, Attribute.align.key: Attribute.align, + Attribute.direction.key: Attribute.direction, Attribute.list.key: Attribute.list, Attribute.codeBlock.key: Attribute.codeBlock, Attribute.blockQuote.key: Attribute.blockQuote, @@ -79,6 +80,8 @@ class Attribute { static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute(); + static final DirectionAttribute direction = DirectionAttribute(null); + static final WidthAttribute width = WidthAttribute(null); static final HeightAttribute height = HeightAttribute(null); @@ -116,6 +119,7 @@ class Attribute { Attribute.codeBlock.key, Attribute.blockQuote.key, Attribute.indent.key, + Attribute.direction.key, }); static final Set blockKeysExceptHeader = LinkedHashSet.of({ @@ -124,6 +128,7 @@ class Attribute { Attribute.codeBlock.key, Attribute.blockQuote.key, Attribute.indent.key, + Attribute.direction.key, }); static final Set exclusiveBlockKeys = LinkedHashSet.of({ @@ -163,6 +168,9 @@ class Attribute { // "attributes":{"list":"unchecked"} static Attribute get unchecked => ListAttribute('unchecked'); + // "attributes":{"direction":"rtl"} + static Attribute get rtl => DirectionAttribute('rtl'); + // "attributes":{"indent":1"} static Attribute get indentL1 => IndentAttribute(level: 1); @@ -309,6 +317,11 @@ class BlockQuoteAttribute extends Attribute { BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true); } +class DirectionAttribute extends Attribute { + DirectionAttribute(String? val) + : super('direction', AttributeScope.BLOCK, val); +} + class WidthAttribute extends Attribute { WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val); } diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index f1db6318..70c1e63e 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -115,7 +115,11 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { delta.insert('\n', lineStyle.toJson()); } else if (i < lines.length - 1) { // we don't want to insert a newline after the last chunk of text, so -1 - delta.insert('\n', blockStyle); + final blockAttributes = blockStyle.isEmpty + ? null + : blockStyle.map((_, attribute) => + MapEntry(attribute.key, attribute.value)); + delta.insert('\n', blockAttributes); } } diff --git a/lib/src/translations/toolbar.i18n.dart b/lib/src/translations/toolbar.i18n.dart index 577f901b..25fa6b8d 100644 --- a/lib/src/translations/toolbar.i18n.dart +++ b/lib/src/translations/toolbar.i18n.dart @@ -141,8 +141,8 @@ extension Localization on String { 'Text': '文字', 'What is entered is not a link': '输入的不是链接', 'Resize': '调整大小', - 'Width': 'Width', - 'Height': 'Height', + 'Width': '宽度', + 'Height': '高度', }, 'ko': { 'Paste a link': '링크를 붙여넣어 주세요.', @@ -308,6 +308,66 @@ extension Localization on String { 'Width': 'Width', 'Height': 'Height', }, + 'ur': { + 'Paste a link': 'لنک پیسٹ کریں', + 'Ok': 'ٹھیک ہے', + 'Select Color': 'رنگ منتخب کریں', + 'Gallery': 'گیلری', + 'Link': 'لنک', + 'Please first select some text to transform into a link.': + 'براہ کرم لنک میں تبدیل کرنے کے لیے پہلے کچھ متن منتخب کریں۔', + 'Open': 'کھولیں', + 'Copy': 'نقل', + 'Remove': 'ہٹا دیں', + 'Save': 'محفوظ کریں', + 'Zoom': 'زوم', + 'Saved': 'محفوظ کر لیا', + 'Text': 'متن', + 'What is entered is not a link': 'جو درج کیا گیا ہے وہ لنک نہیں ہے۔', + 'Resize': 'سائز تبدیل کریں۔', + 'Width': 'چوڑائی', + 'Height': 'اونچائی', + }, + 'id': { + 'Paste a link': 'Tempel tautan', + 'Ok': 'Oke', + 'Select Color': 'Pilih Warna', + 'Gallery': 'Galeri', + 'Link': 'Tautan', + 'Please first select some text to transform into a link.': + 'Silakan pilih dulu beberapa teks untuk diubah menjadi tautan.', + 'Open': 'Buka', + 'Copy': 'Salin', + 'Remove': 'Hapus', + 'Save': 'Simpan', + 'Zoom': 'Perbesar', + 'Saved': 'Tersimpan', + 'Text': 'Teks', + 'What is entered is not a link': 'Yang dimasukkan bukan tautan', + 'Resize': 'Ubah Ukuran', + 'Width': 'Lebar', + 'Height': 'Tinggi', + }, + 'no': { + 'Paste a link': 'Lim inn lenke', + 'Ok': 'Ok', + 'Select Color': 'Velg farge', + 'Gallery': 'Galleri', + 'Link': 'Lenke', + 'Please first select some text to transform into a link.': + 'Velg først litt tekst for å forvandle til en lenke.', + 'Open': 'Åpne', + 'Copy': 'Kopier', + 'Remove': 'Fjern', + 'Save': 'Lagre', + 'Zoom': 'Zoom', + 'Saved': 'Lagret', + 'Text': 'Tekst', + 'What is entered is not a link': 'Du har oppgitt en ugyldig lenke', + 'Resize': 'Endre størrelse', + 'Width': 'Bredde', + 'Height': 'Høyde', + }, }; String get i18n => localize(this, _t); diff --git a/lib/src/utils/delta.dart b/lib/src/utils/delta.dart index cf0e5c63..c737e1ac 100644 --- a/lib/src/utils/delta.dart +++ b/lib/src/utils/delta.dart @@ -1,5 +1,8 @@ import 'dart:math' as math; +import 'dart:ui'; +import '../models/documents/attribute.dart'; +import '../models/documents/nodes/node.dart'; import '../models/quill_delta.dart'; // Diff between two texts - old text and new text @@ -72,3 +75,11 @@ int getPositionDelta(Delta user, Delta actual) { } return diff; } + +TextDirection getDirectionOfNode(Node node) { + final direction = node.style.attributes[Attribute.direction.key]; + if (direction == Attribute.rtl) { + return TextDirection.rtl; + } + return TextDirection.ltr; +} diff --git a/lib/src/utils/platform.dart b/lib/src/utils/platform.dart index 1ee63392..a5c39aa1 100644 --- a/lib/src/utils/platform.dart +++ b/lib/src/utils/platform.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; bool isMobile([TargetPlatform? targetPlatform]) { @@ -23,3 +26,12 @@ bool isAppleOS([TargetPlatform? targetPlatform]) { TargetPlatform.iOS, }.contains(targetPlatform); } + +Future isIOSSimulator() async { + if (Platform.isIOS) { + final deviceInfo = DeviceInfoPlugin(); + final iosInfo = await deviceInfo.iosInfo; + return !iosInfo.isPhysicalDevice; + } + return false; +} diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index 8d52e730..f9447c0e 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -23,6 +23,7 @@ class QuillController extends ChangeNotifier { this.onReplaceText, this.onDelete, this.onSelectionCompleted, + this.onSelectionChanged, }) : _selection = selection, _keepStyleOnNewLine = keepStyleOnNewLine; @@ -52,6 +53,7 @@ class QuillController extends ChangeNotifier { DeleteCallback? onDelete; void Function()? onSelectionCompleted; + void Function(TextSelection textSelection)? onSelectionChanged; /// Store any styles attribute that got toggled by the tap of a button /// and that has not been applied yet. @@ -327,6 +329,8 @@ class QuillController extends ChangeNotifier { _selection = selection.copyWith( baseOffset: math.min(selection.baseOffset, end), extentOffset: math.min(selection.extentOffset, end)); + toggledStyle = Style(); + onSelectionChanged?.call(textSelection); } /// Given offset, find its leaf node in document diff --git a/lib/src/widgets/default_styles.dart b/lib/src/widgets/default_styles.dart index 76cb50e0..4434e03a 100644 --- a/lib/src/widgets/default_styles.dart +++ b/lib/src/widgets/default_styles.dart @@ -202,7 +202,7 @@ class DefaultStyles { final inlineCodeStyle = TextStyle( fontSize: 14, - color: themeData.colorScheme.primaryVariant.withOpacity(0.8), + color: themeData.colorScheme.primary.withOpacity(0.8), fontFamily: fontFamily, ); diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 3e418287..5cbb6e4c 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -342,7 +342,7 @@ class QuillEditor extends StatefulWidget { final CustomStyleBuilder? customStyleBuilder; /// The locale to use for the editor toolbar, defaults to system locale - /// and more https://github.com/singerdmx/flutter-quill#translation-of-toolbar + /// More https://github.com/singerdmx/flutter-quill#translation final Locale? locale; /// Delegate function responsible for showing menu with link actions on @@ -1544,16 +1544,47 @@ class RenderEditor extends RenderEditableContainerBox // End TextLayoutMetrics implementation + QuillVerticalCaretMovementRun startVerticalCaretMovement( + TextPosition startPosition) { + return QuillVerticalCaretMovementRun._( + this, + startPosition, + ); + } + @override void systemFontsDidChange() { super.systemFontsDidChange(); markNeedsLayout(); } +} + +class QuillVerticalCaretMovementRun + extends BidirectionalIterator { + QuillVerticalCaretMovementRun._( + this._editor, + this._currentTextPosition, + ); + + TextPosition _currentTextPosition; + + final RenderEditor _editor; - void debugAssertLayoutUpToDate() { - // no-op? - // this assert was added by Flutter TextEditingActionTarge - // so we have to comply here. + @override + TextPosition get current { + return _currentTextPosition; + } + + @override + bool moveNext() { + _currentTextPosition = _editor.getTextPositionBelow(_currentTextPosition); + return true; + } + + @override + bool movePrevious() { + _currentTextPosition = _editor.getTextPositionAbove(_currentTextPosition); + return true; } } diff --git a/lib/src/widgets/embeds/default_embed_builder.dart b/lib/src/widgets/embeds/default_embed_builder.dart index dab275c5..eb46585e 100644 --- a/lib/src/widgets/embeds/default_embed_builder.dart +++ b/lib/src/widgets/embeds/default_embed_builder.dart @@ -162,6 +162,7 @@ Widget _menuOptionsForReadonlyImage( color: Colors.greenAccent, text: 'Save'.i18n, onPressed: () { + imageUrl = appendFileExtensionToImageUrl(imageUrl); GallerySaver.saveImage(imageUrl).then((_) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text('Saved'.i18n))); diff --git a/lib/src/widgets/embeds/image.dart b/lib/src/widgets/embeds/image.dart index c7e9f9e3..6bbedd18 100644 --- a/lib/src/widgets/embeds/image.dart +++ b/lib/src/widgets/embeds/image.dart @@ -12,6 +12,16 @@ import '../../models/documents/nodes/leaf.dart'; import '../../models/documents/style.dart'; import '../controller.dart'; +const List imageFileExtensions = [ + '.jpeg', + '.png', + '.jpg', + '.gif', + '.webp', + '.tif', + '.heic' +]; + bool isImageBase64(String imageUrl) { return !imageUrl.startsWith('http') && isBase64(imageUrl); } @@ -64,6 +74,25 @@ String standardizeImageUrl(String url) { return url; } +/// This is a bug of Gallery Saver Package. +/// It can not save image that's filename does not end with it's file extension +/// like below. +// "https://firebasestorage.googleapis.com/v0/b/eventat-4ba96.appspot.com/o/2019-Metrology-Events.jpg?alt=media&token=bfc47032-5173-4b3f-86bb-9659f46b362a" +/// If imageUrl does not end with it's file extension, +/// file extension is added to image url for saving. +String appendFileExtensionToImageUrl(String url) { + final endsWithImageFileExtension = imageFileExtensions + .firstWhere((s) => url.toLowerCase().endsWith(s), orElse: () => ''); + if (endsWithImageFileExtension.isNotEmpty) { + return url; + } + + final imageFileExtension = imageFileExtensions + .firstWhere((s) => url.toLowerCase().contains(s), orElse: () => ''); + + return url + imageFileExtension; +} + class ImageTapWrapper extends StatelessWidget { const ImageTapWrapper({ required this.imageUrl, diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 473eea10..05cd99ef 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -19,6 +19,7 @@ import '../models/documents/nodes/embeddable.dart'; import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/node.dart'; import '../models/documents/style.dart'; +import '../utils/delta.dart'; import '../utils/platform.dart'; import 'controller.dart'; import 'cursor.dart'; @@ -211,6 +212,8 @@ class RawEditor extends StatefulWidget { /// and paste, and moving the caret will be disabled. final bool enableInteractiveSelection; + bool get selectionEnabled => enableInteractiveSelection; + /// The [ScrollPhysics] to use when vertically scrolling the input. /// /// If not specified, it will behave according to the current platform. @@ -233,7 +236,6 @@ class RawEditorState extends EditorState AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, - TextEditingActionTarget, RawEditorStateTextInputClientMixin, RawEditorStateSelectionDelegateMixin { final GlobalKey _editorKey = GlobalKey(); @@ -254,9 +256,10 @@ class RawEditorState extends EditorState // Cursors late CursorCont _cursorCont; + QuillController get controller => widget.controller; + // Focus bool _didAutoFocus = false; - FocusAttachment? _focusAttachment; bool get _hasFocus => widget.focusNode.hasFocus; // Theme @@ -280,7 +283,6 @@ class RawEditorState extends EditorState @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); - _focusAttachment!.reparent(); super.build(context); var _doc = widget.controller.document; @@ -362,12 +364,15 @@ class RawEditorState extends EditorState return QuillStyles( data: _styles!, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: QuillKeyboardListener( - child: Container( - constraints: constraints, - child: child, + child: Actions( + actions: _actions, + child: Focus( + focusNode: widget.focusNode, + child: QuillKeyboardListener( + child: Container( + constraints: constraints, + child: child, + ), ), ), ), @@ -432,7 +437,8 @@ class RawEditorState extends EditorState for (final node in doc.root.children) { if (node is Line) { final editableTextLine = _getEditableTextLineFromNode(node, context); - result.add(editableTextLine); + result.add(Directionality( + textDirection: getDirectionOfNode(node), child: editableTextLine)); } else if (node is Block) { final attrs = node.style.attributes; final editableTextBlock = EditableTextBlock( @@ -457,7 +463,8 @@ class RawEditorState extends EditorState onCheckboxTap: _handleCheckboxTap, readOnly: widget.readOnly, customStyleBuilder: widget.customStyleBuilder); - result.add(editableTextBlock); + result.add(Directionality( + textDirection: getDirectionOfNode(node), child: editableTextBlock)); } else { throw StateError('Unreachable.'); } @@ -557,18 +564,25 @@ class RawEditorState extends EditorState if (isKeyboardOS()) { _keyboardVisible = true; } else { - _keyboardVisibilityController = KeyboardVisibilityController(); - _keyboardVisible = _keyboardVisibilityController!.isVisible; - _keyboardVisibilitySubscription = - _keyboardVisibilityController?.onChange.listen((visible) { - _keyboardVisible = visible; - if (visible) { - _onChangeTextEditingValue(!_hasFocus); + // treat iOS Simulator like a keyboard OS + isIOSSimulator().then((isIosSimulator) { + if (isIosSimulator) { + _keyboardVisible = true; + } else { + _keyboardVisibilityController = KeyboardVisibilityController(); + _keyboardVisible = _keyboardVisibilityController!.isVisible; + _keyboardVisibilitySubscription = + _keyboardVisibilityController?.onChange.listen((visible) { + _keyboardVisible = visible; + if (visible) { + _onChangeTextEditingValue(!_hasFocus); + } + }); } }); } - _focusAttachment = widget.focusNode.attach(context); + // Focus widget.focusNode.addListener(_handleFocusChanged); } @@ -612,8 +626,6 @@ class RawEditorState extends EditorState if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocusChanged); - _focusAttachment?.detach(); - _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); updateKeepAlive(); } @@ -651,7 +663,6 @@ class RawEditorState extends EditorState _selectionOverlay = null; widget.controller.removeListener(_didChangeTextEditingValue); widget.focusNode.removeListener(_handleFocusChanged); - _focusAttachment!.detach(); _cursorCont.dispose(); _clipboardStatus ..removeListener(_onChangedClipboardStatus) @@ -828,8 +839,15 @@ class RawEditorState extends EditorState /// This property is typically used to notify the renderer of input gestures. @override RenderEditor get renderEditor => - _editorKey.currentContext?.findRenderObject() as RenderEditor; + _editorKey.currentContext!.findRenderObject() as RenderEditor; + /// Express interest in interacting with the keyboard. + /// + /// If this control is already attached to the keyboard, this function will + /// request that the keyboard become visible. Otherwise, this function will + /// ask the focus system that it become focused. If successful in acquiring + /// focus, the control will then attach to the keyboard and request that the + /// keyboard become visible. @override void requestKeyboard() { if (_hasFocus) { @@ -840,24 +858,6 @@ class RawEditorState extends EditorState } } - @override - void setTextEditingValue( - TextEditingValue value, SelectionChangedCause cause) { - if (value == textEditingValue) { - return; - } - textEditingValue = value; - userUpdateTextEditingValue(value, cause); - - // keyboard and text input force a selection completion - _handleSelectionCompleted(); - } - - @override - void debugAssertLayoutUpToDate() { - renderEditor.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 @@ -880,13 +880,28 @@ class RawEditorState extends EditorState return true; } + void _replaceText(ReplaceTextIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue + .replaced(intent.replacementRange, intent.replacementText), + intent.cause, + ); + } + + /// Copy current selection to [Clipboard]. @override void copySelection(SelectionChangedCause cause) { widget.controller.copiedImageUrl = null; _pastePlainText = widget.controller.getPlainText(); _pasteStyle = widget.controller.getAllIndividualSelectionStyles(); - // Copied straight from EditableTextState - super.copySelection(cause); + + final selection = textEditingValue.selection; + final text = textEditingValue.text; + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); hideToolbar(false); @@ -905,22 +920,37 @@ class RawEditorState extends EditorState } } + /// Cut current selection to [Clipboard]. @override void cutSelection(SelectionChangedCause cause) { widget.controller.copiedImageUrl = null; _pastePlainText = widget.controller.getPlainText(); _pasteStyle = widget.controller.getAllIndividualSelectionStyles(); - // Copied straight from EditableTextState - super.cutSelection(cause); + if (widget.readOnly) { + return; + } + final selection = textEditingValue.selection; + final text = textEditingValue.text; + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + _replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause)); + if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); hideToolbar(); } } + /// Paste text from [Clipboard]. @override Future pasteText(SelectionChangedCause cause) async { + if (widget.readOnly) { + return; + } + if (widget.controller.copiedImageUrl != null) { final index = textEditingValue.selection.baseOffset; final length = textEditingValue.selection.extentOffset - index; @@ -938,8 +968,19 @@ class RawEditorState extends EditorState return; } - // Copied straight from EditableTextState - super.pasteText(cause); // ignore: unawaited_futures + final selection = textEditingValue.selection; + if (!selection.isValid) { + return; + } + // Snapshot the input before using `await`. + // See https://github.com/flutter/flutter/issues/11427 + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data == null) { + return; + } + + _replaceText( + ReplaceTextIntent(textEditingValue, data.text!, selection, cause)); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); @@ -947,10 +988,17 @@ class RawEditorState extends EditorState } } + /// Select the entire text value. @override void selectAll(SelectionChangedCause cause) { - // Copied straight from EditableTextState - super.selectAll(cause); + userUpdateTextEditingValue( + textEditingValue.copyWith( + selection: TextSelection( + baseOffset: 0, extentOffset: textEditingValue.text.length), + ), + cause, + ); + if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); } @@ -960,22 +1008,127 @@ class RawEditorState extends EditorState bool get wantKeepAlive => widget.focusNode.hasFocus; @override - bool get obscureText => false; + AnimationController get floatingCursorResetController => + _floatingCursorResetController; - @override - bool get selectionEnabled => widget.enableInteractiveSelection; + late AnimationController _floatingCursorResetController; - @override - bool get readOnly => widget.readOnly; + // --------------------------- Text Editing Actions -------------------------- - @override - TextLayoutMetrics get textLayoutMetrics => renderEditor; + _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary = + _CharacterBoundary(textEditingValue); + return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward); + } - @override - AnimationController get floatingCursorResetController => - _floatingCursorResetController; + _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + // final TextEditingValue textEditingValue = + // _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + // This isn't enough. Newline characters. + boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), + _WordBoundary(renderEditor, textEditingValue)); + + final mixedBoundary = intent.forward + ? _MixedBoundary(atomicTextBoundary, boundary) + : _MixedBoundary(boundary, atomicTextBoundary); + // Use a _MixedBoundary to make sure we don't leave invalid codepoints in + // the field after deletion. + return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); + } - late AnimationController _floatingCursorResetController; + _TextBoundary _linebreak(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + // final TextEditingValue textEditingValue = + // _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + boundary = _LineBreak(renderEditor, textEditingValue); + + // The _MixedBoundary is to make sure we don't leave invalid code units in + // the field after deletion. + // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, + // since the document boundary is unique and the linebreak boundary is + // already caret-location based. + return intent.forward + ? _MixedBoundary( + _CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) + : _MixedBoundary( + boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false)); + } + + _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => + _DocumentBoundary(textEditingValue); + + Action _makeOverridable(Action defaultAction) { + return Action.overridable( + context: context, defaultAction: defaultAction); + } + + late final Action _replaceTextAction = + CallbackAction(onInvoke: _replaceText); + + void _updateSelection(UpdateSelectionIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.cause, + ); + } + + late final Action _updateSelectionAction = + CallbackAction(onInvoke: _updateSelection); + + late final _UpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = + _UpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent>(this); + + late final Map> _actions = >{ + DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), + ReplaceTextIntent: _replaceTextAction, + UpdateSelectionIntent: _updateSelectionAction, + DirectionalFocusIntent: DirectionalFocusAction.forTextField(), + + // Delete + DeleteCharacterIntent: _makeOverridable( + _DeleteTextAction(this, _characterBoundary)), + DeleteToNextWordBoundaryIntent: _makeOverridable( + _DeleteTextAction( + this, _nextWordBoundary)), + DeleteToLineBreakIntent: _makeOverridable( + _DeleteTextAction(this, _linebreak)), + + // Extend/Move Selection + ExtendSelectionByCharacterIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + false, + _characterBoundary, + )), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _nextWordBoundary)), + ExtendSelectionToLineBreakIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _linebreak)), + ExtendSelectionVerticallyToAdjacentLineIntent: + _makeOverridable(_adjacentLineAction), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _documentBoundary)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + _ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), + + // Copy Paste + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + PasteTextIntent: _makeOverridable(CallbackAction( + onInvoke: (intent) => pasteText(intent.cause))), + }; } class _Editor extends MultiChildRenderObjectWidget { @@ -1053,3 +1206,609 @@ class _Editor extends MultiChildRenderObjectWidget { ..maxContentWidth = maxContentWidth; } } + +/// An interface for retrieving the logical text boundary +/// (left-closed-right-open) +/// at a given location in a document. +/// +/// Depending on the implementation of the [_TextBoundary], the input +/// [TextPosition] can either point to a code unit, or a position between 2 code +/// units (which can be visually represented by the caret if the selection were +/// to collapse to that position). +/// +/// For example, [_LineBreak] interprets the input [TextPosition] as a caret +/// location, since in Flutter the caret is generally painted between the +/// character the [TextPosition] points to and its previous character, and +/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most +/// other text boundaries however, interpret the input [TextPosition] as the +/// location of a code unit in the document, since it's easier to reason about +/// the text boundary given a code unit in the text. +/// +/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based", +/// use the [_CollapsedSelectionBoundary] combinator. +abstract class _TextBoundary { + const _TextBoundary(); + + TextEditingValue get textEditingValue; + + /// Returns the leading text boundary at the given location, inclusive. + TextPosition getLeadingTextBoundaryAt(TextPosition position); + + /// Returns the trailing text boundary at the given location, exclusive. + TextPosition getTrailingTextBoundaryAt(TextPosition position); + + TextRange getTextBoundaryAt(TextPosition position) { + return TextRange( + start: getLeadingTextBoundaryAt(position).offset, + end: getTrailingTextBoundaryAt(position).offset, + ); + } +} + +// ----------------------------- Text Boundaries ----------------------------- + +// The word modifier generally removes the word boundaries around white spaces +// (and newlines), IOW white spaces and some other punctuations are considered +// a part of the next word in the search direction. +class _WhitespaceBoundary extends _TextBoundary { + const _WhitespaceBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; index >= 0; index -= 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index); + } + } + return const TextPosition(offset: 0); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; + index < textEditingValue.text.length; + index += 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index + 1); + } + } + return TextPosition(offset: textEditingValue.text.length); + } +} + +// Most apps delete the entire grapheme when the backspace key is pressed. +// Also always put the new caret location to character boundaries to avoid +// sending malformed UTF-16 code units to the paragraph builder. +class _CharacterBoundary extends _TextBoundary { + const _CharacterBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + return TextPosition( + offset: + CharacterRange.at(textEditingValue.text, position.offset, endOffset) + .stringBeforeLength, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextPosition( + offset: textEditingValue.text.length - range.stringAfterLength, + ); + } + + @override + TextRange getTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextRange( + start: range.stringBeforeLength, + end: textEditingValue.text.length - range.stringAfterLength, + ); + } +} + +// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. +class _WordBoundary extends _TextBoundary { + const _WordBoundary(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).start, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).end, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } +} + +// The linebreaks of the current text layout. The input [TextPosition]s are +// interpreted as caret locations because [TextPainter.getLineAtOffset] is +// text-affinity-aware. +class _LineBreak extends _TextBoundary { + const _LineBreak(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).start, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + } +} + +// The document boundary is unique and is a constant function of the input +// position. +class _DocumentBoundary extends _TextBoundary { + const _DocumentBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + const TextPosition(offset: 0); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textEditingValue.text.length, + affinity: TextAffinity.upstream, + ); + } +} + +// ------------------------ Text Boundary Combinators ------------------------ + +// Expands the innerTextBoundary with outerTextBoundary. +class _ExpandedTextBoundary extends _TextBoundary { + _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary); + + final _TextBoundary innerTextBoundary; + final _TextBoundary outerTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(innerTextBoundary.textEditingValue == + outerTextBoundary.textEditingValue); + return innerTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getLeadingTextBoundaryAt( + innerTextBoundary.getLeadingTextBoundaryAt(position), + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getTrailingTextBoundaryAt( + innerTextBoundary.getTrailingTextBoundaryAt(position), + ); + } +} + +// Force the innerTextBoundary to interpret the input [TextPosition]s as caret +// locations instead of code unit positions. +// +// The innerTextBoundary must be a [_TextBoundary] that interprets the input +// [TextPosition]s as code unit positions. +class _CollapsedSelectionBoundary extends _TextBoundary { + _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); + + final _TextBoundary innerTextBoundary; + final bool isForward; + + @override + TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getLeadingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getLeadingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getTrailingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getTrailingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } +} + +// A _TextBoundary that creates a [TextRange] where its start is from the +// specified leading text boundary and its end is from the specified trailing +// text boundary. +class _MixedBoundary extends _TextBoundary { + _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); + + final _TextBoundary leadingTextBoundary; + final _TextBoundary trailingTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(leadingTextBoundary.textEditingValue == + trailingTextBoundary.textEditingValue); + return leadingTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + leadingTextBoundary.getLeadingTextBoundaryAt(position); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => + trailingTextBoundary.getTrailingTextBoundaryAt(position); +} + +// ------------------------------- Text Actions ------------------------------- +class _DeleteTextAction + extends ContextAction { + _DeleteTextAction(this.state, this.getTextBoundariesForIntent); + + final RawEditorState state; + final _TextBoundary Function(T intent) getTextBoundariesForIntent; + + TextRange _expandNonCollapsedRange(TextEditingValue value) { + final TextRange selection = value.selection; + assert(selection.isValid); + assert(!selection.isCollapsed); + final _TextBoundary atomicBoundary = _CharacterBoundary(value); + + return TextRange( + start: atomicBoundary + .getLeadingTextBoundaryAt(TextPosition(offset: selection.start)) + .offset, + end: atomicBoundary + .getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)) + .offset, + ); + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + final selection = state.textEditingValue.selection; + assert(selection.isValid); + + if (!selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent( + state.textEditingValue, + '', + _expandNonCollapsedRange(state.textEditingValue), + SelectionChangedCause.keyboard), + ); + } + + final textBoundary = getTextBoundariesForIntent(intent); + if (!textBoundary.textEditingValue.selection.isValid) { + return null; + } + if (!textBoundary.textEditingValue.selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent( + state.textEditingValue, + '', + _expandNonCollapsedRange(textBoundary.textEditingValue), + SelectionChangedCause.keyboard), + ); + } + + return Actions.invoke( + context!, + ReplaceTextIntent( + textBoundary.textEditingValue, + '', + textBoundary + .getTextBoundaryAt(textBoundary.textEditingValue.selection.base), + SelectionChangedCause.keyboard, + ), + ); + } + + @override + bool get isActionEnabled => + !state.widget.readOnly && state.textEditingValue.selection.isValid; +} + +class _UpdateTextSelectionAction + extends ContextAction { + _UpdateTextSelectionAction(this.state, this.ignoreNonCollapsedSelection, + this.getTextBoundariesForIntent); + + final RawEditorState state; + final bool ignoreNonCollapsedSelection; + final _TextBoundary Function(T intent) getTextBoundariesForIntent; + + @override + Object? invoke(T intent, [BuildContext? context]) { + final selection = state.textEditingValue.selection; + assert(selection.isValid); + + final collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + // Collapse to the logical start/end. + TextSelection _collapse(TextSelection selection) { + assert(selection.isValid); + assert(!selection.isCollapsed); + return selection.copyWith( + baseOffset: intent.forward ? selection.end : selection.start, + extentOffset: intent.forward ? selection.end : selection.start, + ); + } + + if (!selection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent(state.textEditingValue, _collapse(selection), + SelectionChangedCause.keyboard), + ); + } + + final textBoundary = getTextBoundariesForIntent(intent); + final textBoundarySelection = textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + if (!textBoundarySelection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent(state.textEditingValue, + _collapse(textBoundarySelection), SelectionChangedCause.keyboard), + ); + } + + final extent = textBoundarySelection.extent; + final newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : textBoundarySelection.extendTo(newExtent); + + // If collapseAtReversal is true and would have an effect, collapse it. + if (!selection.isCollapsed && + intent.collapseAtReversal && + (selection.baseOffset < selection.extentOffset != + newSelection.baseOffset < newSelection.extentOffset)) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state.textEditingValue, + TextSelection.fromPosition(selection.base), + SelectionChangedCause.keyboard, + ), + ); + } + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, + SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => state.textEditingValue.selection.isValid; +} + +class _ExtendSelectionOrCaretPositionAction extends ContextAction< + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent> { + _ExtendSelectionOrCaretPositionAction( + this.state, this.getTextBoundariesForIntent); + + final RawEditorState state; + final _TextBoundary Function( + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) + getTextBoundariesForIntent; + + @override + Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, + [BuildContext? context]) { + final selection = state.textEditingValue.selection; + assert(selection.isValid); + + final textBoundary = getTextBoundariesForIntent(intent); + final textBoundarySelection = textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + + final extent = textBoundarySelection.extent; + final newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final newSelection = (newExtent.offset - textBoundarySelection.baseOffset) * + (textBoundarySelection.extentOffset - + textBoundarySelection.baseOffset) < + 0 + ? textBoundarySelection.copyWith( + extentOffset: textBoundarySelection.baseOffset, + affinity: textBoundarySelection.extentOffset > + textBoundarySelection.baseOffset + ? TextAffinity.downstream + : TextAffinity.upstream, + ) + : textBoundarySelection.extendTo(newExtent); + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, + SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => + state.widget.selectionEnabled && state.textEditingValue.selection.isValid; +} + +class _UpdateTextSelectionToAdjacentLineAction< + T extends DirectionalCaretMovementIntent> extends ContextAction { + _UpdateTextSelectionToAdjacentLineAction(this.state); + + final RawEditorState state; + + QuillVerticalCaretMovementRun? _verticalMovementRun; + TextSelection? _runSelection; + + void stopCurrentVerticalRunIfSelectionChanges() { + final runSelection = _runSelection; + if (runSelection == null) { + assert(_verticalMovementRun == null); + return; + } + _runSelection = state.textEditingValue.selection; + final currentSelection = state.widget.controller.selection; + final continueCurrentRun = currentSelection.isValid && + currentSelection.isCollapsed && + currentSelection.baseOffset == runSelection.baseOffset && + currentSelection.extentOffset == runSelection.extentOffset; + if (!continueCurrentRun) { + _verticalMovementRun = null; + _runSelection = null; + } + } + + @override + void invoke(T intent, [BuildContext? context]) { + assert(state.textEditingValue.selection.isValid); + + final collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + final value = state.textEditingValue; + if (!value.selection.isValid) { + return; + } + + final currentRun = _verticalMovementRun ?? + state.renderEditor + .startVerticalCaretMovement(state.renderEditor.selection.extent); + + final shouldMove = + intent.forward ? currentRun.moveNext() : currentRun.movePrevious(); + final newExtent = shouldMove + ? currentRun.current + : (intent.forward + ? TextPosition(offset: state.textEditingValue.text.length) + : const TextPosition(offset: 0)); + final newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : value.selection.extendTo(newExtent); + + Actions.invoke( + context!, + UpdateSelectionIntent( + value, newSelection, SelectionChangedCause.keyboard), + ); + if (state.textEditingValue.selection == newSelection) { + _verticalMovementRun = currentRun; + _runSelection = newSelection; + } + } + + @override + bool get isActionEnabled => state.textEditingValue.selection.isValid; +} + +class _SelectAllAction extends ContextAction { + _SelectAllAction(this.state); + + final RawEditorState state; + + @override + Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state.textEditingValue, + TextSelection( + baseOffset: 0, extentOffset: state.textEditingValue.text.length), + intent.cause, + ), + ); + } + + @override + bool get isActionEnabled => state.widget.selectionEnabled; +} + +class _CopySelectionAction extends ContextAction { + _CopySelectionAction(this.state); + + final RawEditorState state; + + @override + void invoke(CopySelectionTextIntent intent, [BuildContext? context]) { + if (intent.collapseSelection) { + state.cutSelection(intent.cause); + } else { + state.copySelection(intent.cause); + } + } + + @override + bool get isActionEnabled => + state.textEditingValue.selection.isValid && + !state.textEditingValue.selection.isCollapsed; +} diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index 3ab0f439..26305179 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -5,6 +5,7 @@ import 'package:tuple/tuple.dart'; import '../../flutter_quill.dart'; import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/line.dart'; +import '../utils/delta.dart'; import 'box.dart'; import 'cursor.dart'; import 'delegate.dart'; @@ -146,7 +147,9 @@ class EditableTextBlock extends StatelessWidget { hasFocus, MediaQuery.of(context).devicePixelRatio, cursorCont); - children.add(editableTextLine); + final nodeTextDirection = getDirectionOfNode(line); + children.add(Directionality( + textDirection: nodeTextDirection, child: editableTextLine)); } return children.toList(growable: false); } diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 454fa415..e001c9d9 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -100,6 +100,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { bool showImageButton = true, bool showVideoButton = true, bool showCameraButton = true, + bool showDirection = false, OnImagePickCallback? onImagePickCallback, OnVideoPickCallback? onVideoPickCallback, MediaPickSettingSelector? mediaPickSettingSelector, @@ -114,13 +115,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ///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 + /// The locale to use for the editor toolbar, defaults to system locale + /// More at https://github.com/singerdmx/flutter-quill#translation Locale? locale, Key? key, }) { @@ -136,7 +132,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { showClearFormat || onImagePickCallback != null || onVideoPickCallback != null, - showAlignmentButtons, + showAlignmentButtons || showDirection, showLeftAlignment, showCenterAlignment, showRightAlignment, @@ -301,6 +297,14 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { showRightAlignment: showRightAlignment, showJustifyAlignment: showJustifyAlignment, ), + if (showDirection) + ToggleStyleButton( + attribute: Attribute.rtl, + controller: controller, + icon: Icons.format_textdirection_r_to_l, + iconSize: toolbarIconSize, + iconTheme: iconTheme, + ), if (showDividers && isButtonGroupShown[1] && (isButtonGroupShown[2] || @@ -425,7 +429,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { final FilePickImpl? filePickImpl; /// The locale to use for the editor toolbar, defaults to system locale - /// and more https://github.com/singerdmx/flutter-quill#translation-of-toolbar + /// More https://github.com/singerdmx/flutter-quill#translation final Locale? locale; @override diff --git a/lib/src/widgets/toolbar/link_style_button.dart b/lib/src/widgets/toolbar/link_style_button.dart index 0ccdfc77..9ca83fb6 100644 --- a/lib/src/widgets/toolbar/link_style_button.dart +++ b/lib/src/widgets/toolbar/link_style_button.dart @@ -114,12 +114,17 @@ class _LinkStyleButtonState extends State { } } - text ??= widget.controller.document - .getPlainText(index, widget.controller.selection.end - index); + final len = widget.controller.selection.end - index; + text ??= + len == 0 ? '' : widget.controller.document.getPlainText(index, len); return _LinkDialog( dialogTheme: widget.dialogTheme, link: link, text: text); }, - ).then(_linkSubmitted); + ).then( + (value) { + if (value != null) _linkSubmitted(value); + }, + ); } String? _getLinkAttributeValue() { diff --git a/pubspec.yaml b/pubspec.yaml index 3737784f..31ab938a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,13 @@ name: flutter_quill description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 3.9.6 +version: 4.0.5 #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: ">=2.5.3" + flutter: ">=2.10.0" dependencies: flutter: @@ -32,6 +32,7 @@ dependencies: diff_match_patch: ^0.4.1 i18n_extension: ^4.2.0 gallery_saver: ^2.3.2 + device_info_plus: ^3.2.1 dev_dependencies: flutter_test: