diff --git a/.travis.yml b/.travis.yml index bdc01e855..3ff906b1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: dart dart: + - stable - dev os: @@ -21,7 +22,7 @@ cache: - $HOME/.pub-cache env: -# - FLUTTER_VERSION=beta + - FLUTTER_VERSION=beta - FLUTTER_VERSION=dev matrix: @@ -37,4 +38,4 @@ script: - pwd - ./tool/travis.sh notus - ./tool/travis.sh zefyr -- bash <(curl -s https://codecov.io/bash) +#- bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index df60857ea..e3dae5a8b 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,14 @@ request or found a bug, please file it at the [issue tracker][]. * [Data Format and Document Model][data_and_document] * [Style attributes][attributes] * [Heuristic rules][heuristics] +* [Images][images] * [FAQ][faq] [quick_start]: /doc/quick_start.md [data_and_document]: /doc/data_and_document.md [attributes]: /doc/attributes.md [heuristics]: /doc/heuristics.md +[images]: /doc/images.md [faq]: /doc/faq.md ## Clean and modern look diff --git a/doc/heuristics.md b/doc/heuristics.md index 981bbd8cb..2aac45467 100644 --- a/doc/heuristics.md +++ b/doc/heuristics.md @@ -87,3 +87,15 @@ When composing a change which came from a different site or server make sure to use `ChangeSource.remote` when calling `compose()`. This allows you to distinguish such changes from local changes made by the user when listening on `NotusDocument.changes` stream. + +### Next up + +* [Images][images] + +[images]: /doc/images.md + +### Previous + +* [Style attributes][attributes] + +[attributes]: /doc/attributes.md diff --git a/doc/images.md b/doc/images.md new file mode 100644 index 000000000..e480a9718 --- /dev/null +++ b/doc/images.md @@ -0,0 +1,114 @@ +## Images + +> Note that described API is considered experimental and is likely to be +> changed in backward incompatible ways. If this happens all changes will be +> described in detail in the changelog to simplify upgrading. + +Zefyr (and Notus) supports embedded images. In order to handle images in +your application you need to implement `ZefyrImageDelegate` interface which +looks like this: + +```dart +abstract class ZefyrImageDelegate { + /// Builds image widget for specified [imageSource] and [context]. + Widget buildImage(BuildContext context, String imageSource); + + /// Picks an image from specified [source]. + /// + /// Returns unique string key for the selected image. Returned key is stored + /// in the document. + Future pickImage(S source); +} +``` + +Zefyr comes with default implementation which exists mostly to provide an +example and a starting point for your own version. + +It is recommended to always have your own implementation specific to your +application. + +### Implementing ZefyrImageDelegate + +Let's start from the `pickImage` method: + +```dart +// Currently Zefyr depends on image_picker plugin to show camera or image gallery. +// (note that in future versions this may change so that users can choose their +// own plugin and define custom sources) +import 'package:image_picker/image_picker.dart'; + +class MyAppZefyrImageDelegate implements ZefyrImageDelegate { + @override + Future pickImage(ImageSource source) async { + final file = await ImagePicker.pickImage(source: source); + if (file == null) return null; + // We simply return the absolute path to selected file. + return file.uri.toString(); + } +} +``` + +This method is responsible for initiating image selection flow (either using +camera or gallery), handling result of selection and returning a string value +which essentially serves as an identifier for the image. + +Returned value is stored in the document Delta and later on used to build the +appropriate `Widget`. + +It is up to the developer to define what this value represents. + +In the above example we simply return a full path to the file on user's device, +e.g. `file:///Users/something/something/image.jpg`. Some other examples +may include a web link, `https://myapp.com/images/some.jpg` or just some +arbitrary string like an ID. + +For instance, if you upload files to your server you can initiate this task +in `pickImage`, for instance: + +```dart +class MyAppZefyrImageDelegate implements ZefyrImageDelegate { + final MyFileStorage storage; + MyAppZefyrImageDelegate(this.storage); + + @override + Future pickImage(ImageSource source) async { + final file = await ImagePicker.pickImage(source: source); + if (file == null) return null; + // Use my storage service to upload selected file. The uploadImage method + // returns unique ID of newly uploaded image on my server. + final String imageId = await storage.uploadImage(file); + return imageId; + } +} +``` + +Next we need to implement `buildImage`. This method takes `imageSource` argument +which contains that same string you returned from `pickImage`. Here you can +use this value to create a Flutter `Widget` which renders the image. Normally +you would return the standard `Image` widget from this method, but it is not +a requirement. You are free to create a custom widget which, for instance, +shows progress of upload operation that you initiated in the `pickImage` call. + +Assuming our first example where we returned full path to the image file on +user's device, our `buildImage` method can be as simple as following: + +```dart +class MyAppZefyrImageDelegate implements ZefyrImageDelegate { + // ... + + @override + Widget buildImage(BuildContext context, String imageSource) { + final file = new File.fromUri(Uri.parse(imageSource)); + /// Create standard [FileImage] provider. If [imageSource] was an HTTP link + /// we could use [NetworkImage] instead. + final image = new FileImage(file); + return new Image(image: image); + } +} +``` + +### Previous + +* [Heuristics][heuristics] + +[heuristics]: /doc/heuristics.md \ No newline at end of file diff --git a/packages/notus/CHANGELOG.md b/packages/notus/CHANGELOG.md index d065d6ba3..539e3a7fc 100644 --- a/packages/notus/CHANGELOG.md +++ b/packages/notus/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.1.3 + +- Fixed handling of user input around embeds. +- Added new heuristic rule to preserve block style on paste + ## 0.1.2 * Upgraded dependency on quiver_hashcode to 2.0.0. diff --git a/packages/notus/lib/src/document.dart b/packages/notus/lib/src/document.dart index 0a995dc10..720d1dfff 100644 --- a/packages/notus/lib/src/document.dart +++ b/packages/notus/lib/src/document.dart @@ -226,7 +226,7 @@ class NotusDocument { if (_delta != _root.toDelta()) { throw new StateError('Compose produced inconsistent results. ' - 'This is likely due to a bug in the library.'); + 'This is likely due to a bug in the library. Tried to compose change $change from $source.'); } _controller.add(new NotusChange(before, change, source)); } diff --git a/packages/notus/lib/src/heuristics.dart b/packages/notus/lib/src/heuristics.dart index a58ceba2d..9ef2b78c7 100644 --- a/packages/notus/lib/src/heuristics.dart +++ b/packages/notus/lib/src/heuristics.dart @@ -23,6 +23,7 @@ class NotusHeuristics { // attributes. ], insertRules: [ + PreserveBlockStyleOnPasteRule(), ForceNewlineForInsertsAroundEmbedRule(), PreserveLineStyleOnSplitRule(), AutoExitBlockRule(), diff --git a/packages/notus/lib/src/heuristics/delete_rules.dart b/packages/notus/lib/src/heuristics/delete_rules.dart index c30f6d8cb..5f0e2b2cb 100644 --- a/packages/notus/lib/src/heuristics/delete_rules.dart +++ b/packages/notus/lib/src/heuristics/delete_rules.dart @@ -80,12 +80,14 @@ class EnsureEmbedLineRule extends DeleteRule { @override Delta apply(Delta document, int index, int length) { DeltaIterator iter = new DeltaIterator(document); + // First, check if line-break deleted after an embed. Operation op = iter.skip(index); int indexDelta = 0; int lengthDelta = 0; int remaining = length; bool foundEmbed = false; + bool hasLineBreakBefore = false; if (op != null && op.data.endsWith(kZeroWidthSpace)) { foundEmbed = true; Operation candidate = iter.next(1); @@ -102,17 +104,23 @@ class EnsureEmbedLineRule extends DeleteRule { lengthDelta += 1; } } + } else { + // If op is `null` it's a beginning of the doc, e.g. implicit line break. + hasLineBreakBefore = op == null || op.data.endsWith('\n'); } // Second, check if line-break deleted before an embed. op = iter.skip(remaining); if (op != null && op.data.endsWith('\n')) { final candidate = iter.next(1); - if (candidate.data == kZeroWidthSpace) { + // If there is a line-break before deleted range we allow the operation + // since it results in a correctly formatted line with single embed in it. + if (candidate.data == kZeroWidthSpace && !hasLineBreakBefore) { foundEmbed = true; lengthDelta -= 1; } } + if (foundEmbed) { return new Delta() ..retain(index + indexDelta) diff --git a/packages/notus/lib/src/heuristics/insert_rules.dart b/packages/notus/lib/src/heuristics/insert_rules.dart index 127d45f2f..da1c96630 100644 --- a/packages/notus/lib/src/heuristics/insert_rules.dart +++ b/packages/notus/lib/src/heuristics/insert_rules.dart @@ -72,8 +72,9 @@ class PreserveLineStyleOnSplitRule extends InsertRule { } } -/// Resets format for a newly inserted line when insert occurred at the end /// of a line (right before a line-break). + +/// Resets format for a newly inserted line when insert occurred at the end class ResetLineFormatOnNewLineRule extends InsertRule { const ResetLineFormatOnNewLineRule(); @@ -256,3 +257,69 @@ class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { return null; } } + +/// Preserves block style when user pastes text containing line-breaks. +/// This rule may also be activated for changes triggered by auto-correct. +class PreserveBlockStyleOnPasteRule extends InsertRule { + const PreserveBlockStyleOnPasteRule(); + + bool isEdgeLineSplit(Operation before, Operation after) { + if (before == null) return true; // split at the beginning of a doc + return before.data.endsWith('\n') || after.data.startsWith('\n'); + } + + @override + Delta apply(Delta document, int index, String text) { + if (!text.contains('\n') || text.length == 1) { + // Only interested in text containing at least one line-break and at least + // one more character. + return null; + } + + DeltaIterator iter = new DeltaIterator(document); + iter.skip(index); + + // Look for next line-break. + Map lineStyle; + while (iter.hasNext) { + final op = iter.next(); + int lf = op.data.indexOf('\n'); + if (lf >= 0) { + lineStyle = op.attributes; + break; + } + } + + Map resetStyle = null; + Map blockStyle = null; + if (lineStyle != null) { + if (lineStyle.containsKey(NotusAttribute.heading.key)) { + resetStyle = NotusAttribute.heading.unset.toJson(); + } + + if (lineStyle.containsKey(NotusAttribute.block.key)) { + blockStyle = { + NotusAttribute.block.key: lineStyle[NotusAttribute.block.key] + }; + } + } + + final lines = text.split('\n'); + Delta result = new Delta()..retain(index); + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + if (line.isNotEmpty) { + result.insert(line); + } + if (i == 0) { + result.insert('\n', lineStyle); + } else if (i == lines.length - 1) { + if (resetStyle != null) result.retain(1, resetStyle); + } else { + result.insert('\n', blockStyle); + } + } + + return result; + } +} diff --git a/packages/notus/pubspec.yaml b/packages/notus/pubspec.yaml index b2c0e2661..af8d04938 100644 --- a/packages/notus/pubspec.yaml +++ b/packages/notus/pubspec.yaml @@ -1,6 +1,6 @@ name: notus description: Platform-agnostic rich text document model based on Delta format and used in Zefyr editor. -version: 0.1.2 +version: 0.1.3 author: Anatoly Pulyaevskiy homepage: https://github.com/memspace/zefyr diff --git a/packages/notus/test/heuristics/delete_rules_test.dart b/packages/notus/test/heuristics/delete_rules_test.dart index 6f69f29c9..cca899b8c 100644 --- a/packages/notus/test/heuristics/delete_rules_test.dart +++ b/packages/notus/test/heuristics/delete_rules_test.dart @@ -110,5 +110,19 @@ void main() { ..delete(1); expect(actual, expected); }); + + test('allows deleting empty line(s) before embed', () { + final hr = NotusAttribute.embed.horizontalRule; + final doc = new Delta() + ..insert('Document\n') + ..insert('\n') + ..insert('\n') + ..insert(kZeroWidthSpace, hr.toJson()) + ..insert('\n') + ..insert('Text') + ..insert('\n'); + final actual = rule.apply(doc, 11, 1); + expect(actual, isNull); + }); }); } diff --git a/packages/notus/test/heuristics/insert_rules_test.dart b/packages/notus/test/heuristics/insert_rules_test.dart index 397221759..675415589 100644 --- a/packages/notus/test/heuristics/insert_rules_test.dart +++ b/packages/notus/test/heuristics/insert_rules_test.dart @@ -73,8 +73,7 @@ void main() { }); test('applies at the beginning of a document', () { - final doc = new Delta() - ..insert('\n', NotusAttribute.h1.toJson()); + final doc = new Delta()..insert('\n', NotusAttribute.h1.toJson()); final actual = rule.apply(doc, 0, '\n'); expect(actual, isNotNull); final expected = new Delta() @@ -212,4 +211,23 @@ void main() { expect(actual, isNull); }); }); + + group('$PreserveBlockStyleOnPasteRule', () { + final rule = new PreserveBlockStyleOnPasteRule(); + + test('applies in a block', () { + final doc = new Delta() + ..insert('One and two') + ..insert('\n', ul) + ..insert('Three') + ..insert('\n', ul); + final actual = rule.apply(doc, 8, 'also \n'); + final expected = new Delta() + ..retain(8) + ..insert('also ') + ..insert('\n', ul); + expect(actual, isNotNull); + expect(actual, expected); + }); + }); } diff --git a/packages/zefyr/.gitignore b/packages/zefyr/.gitignore index 41794c043..0ff7b798f 100644 --- a/packages/zefyr/.gitignore +++ b/packages/zefyr/.gitignore @@ -13,3 +13,4 @@ ios/Flutter/Generated.xcconfig example/ios/.symlinks example/ios/Flutter/Generated.xcconfig doc/api/ +build/ diff --git a/packages/zefyr/CHANGELOG.md b/packages/zefyr/CHANGELOG.md index 991643dfb..46f22dd7c 100644 --- a/packages/zefyr/CHANGELOG.md +++ b/packages/zefyr/CHANGELOG.md @@ -1,8 +1,10 @@ -## 0.1.3 +## 0.2.0 * Breaking change: `ZefyrImageDelegate.createImageProvider` replaced with `ZefyrImageDelegate.buildImage`. -* Fixed: Prevent redundant updates on composing range for Android. +* Fixed redundant updates on composing range for Android. +* Added TextCapitalization.sentences +* Added docs for embedding images. ## 0.1.2 diff --git a/packages/zefyr/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/zefyr/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/packages/zefyr/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/zefyr/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/zefyr/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/packages/zefyr/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/zefyr/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/zefyr/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..949b67898 --- /dev/null +++ b/packages/zefyr/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + BuildSystemType + Original + + diff --git a/packages/zefyr/example/pubspec.yaml b/packages/zefyr/example/pubspec.yaml index 5acca6f38..1e3fd1493 100644 --- a/packages/zefyr/example/pubspec.yaml +++ b/packages/zefyr/example/pubspec.yaml @@ -19,6 +19,10 @@ dependencies: zefyr: path: ../ +dependency_overrides: + notus: + path: ../../notus + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/zefyr/lib/src/widgets/buttons.dart b/packages/zefyr/lib/src/widgets/buttons.dart index 5f6303261..b96bc0a08 100644 --- a/packages/zefyr/lib/src/widgets/buttons.dart +++ b/packages/zefyr/lib/src/widgets/buttons.dart @@ -65,8 +65,8 @@ class ZefyrButton extends StatelessWidget { @override Widget build(BuildContext context) { - final editor = ZefyrEditor.of(context); final toolbar = ZefyrToolbar.of(context); + final editor = toolbar.editor; final toolbarTheme = ZefyrTheme.of(context).toolbarTheme; final pressedHandler = _getPressedHandler(editor, toolbar); final iconColor = (pressedHandler == null) @@ -236,7 +236,7 @@ class _HeadingButtonState extends State { } } -/// Controls heading styles. +/// Controls image attribute. /// /// When pressed, this button displays overlay toolbar with three /// buttons for each heading level. @@ -278,14 +278,14 @@ class _ImageButtonState extends State { } void _pickFromCamera() async { - final editor = ZefyrEditor.of(context); + final editor = ZefyrToolbar.of(context).editor; final image = await editor.imageDelegate.pickImage(ImageSource.camera); if (image != null) editor.formatSelection(NotusAttribute.embed.image(image)); } void _pickFromGallery() async { - final editor = ZefyrEditor.of(context); + final editor = ZefyrToolbar.of(context).editor; final image = await editor.imageDelegate.pickImage(ImageSource.gallery); if (image != null) editor.formatSelection(NotusAttribute.embed.image(image)); @@ -307,8 +307,8 @@ class _LinkButtonState extends State { @override Widget build(BuildContext context) { - final editor = ZefyrEditor.of(context); final toolbar = ZefyrToolbar.of(context); + final editor = toolbar.editor; final enabled = hasLink(editor.selectionStyle) || !editor.selection.isCollapsed; @@ -322,7 +322,7 @@ class _LinkButtonState extends State { bool hasLink(NotusStyle style) => style.contains(NotusAttribute.link); String getLink([String defaultValue]) { - final editor = ZefyrEditor.of(context); + final editor = ZefyrToolbar.of(context).editor; final attrs = editor.selectionStyle; if (hasLink(attrs)) { return attrs.value(NotusAttribute.link); @@ -351,7 +351,6 @@ class _LinkButtonState extends State { } void doneEdit() { - final editor = ZefyrEditor.of(context); final toolbar = ZefyrToolbar.of(context); setState(() { var error = false; @@ -360,7 +359,7 @@ class _LinkButtonState extends State { var uri = Uri.parse(_inputController.text); if ((uri.isScheme('https') || uri.isScheme('http')) && uri.host.isNotEmpty) { - editor.formatSelection( + toolbar.editor.formatSelection( NotusAttribute.link.fromString(_inputController.text)); } else { error = true; @@ -377,14 +376,14 @@ class _LinkButtonState extends State { _inputController.text = ''; _inputController.removeListener(_handleInputChange); toolbar.markNeedsRebuild(); - editor.focus(context); + toolbar.editor.focus(context); } }); } void cancelEdit() { if (mounted) { - final editor = ZefyrEditor.of(context); + final editor = ZefyrToolbar.of(context).editor; setState(() { _inputKey = null; _inputController.text = ''; @@ -395,7 +394,7 @@ class _LinkButtonState extends State { } void unlink() { - final editor = ZefyrEditor.of(context); + final editor = ZefyrToolbar.of(context).editor; editor.formatSelection(NotusAttribute.link.unset); closeOverlay(); } @@ -407,7 +406,7 @@ class _LinkButtonState extends State { } void openInBrowser() async { - final editor = ZefyrEditor.of(context); + final editor = ZefyrToolbar.of(context).editor; var link = getLink(); assert(link != null); if (await canLaunch(link)) { @@ -425,9 +424,8 @@ class _LinkButtonState extends State { } Widget buildOverlay(BuildContext context) { - final editor = ZefyrEditor.of(context); final toolbar = ZefyrToolbar.of(context); - final style = editor.selectionStyle; + final style = toolbar.editor.selectionStyle; String value = 'Tap to edit link'; if (style.contains(NotusAttribute.link)) { @@ -439,7 +437,7 @@ class _LinkButtonState extends State { : _LinkInput( key: _inputKey, controller: _inputController, - focusNode: editor.toolbarFocusNode, + focusNode: toolbar.editor.toolbarFocusNode, formatError: _formatError, ); final items = [Expanded(child: body)]; diff --git a/packages/zefyr/lib/src/widgets/controller.dart b/packages/zefyr/lib/src/widgets/controller.dart index bde5cae8f..325769d36 100644 --- a/packages/zefyr/lib/src/widgets/controller.dart +++ b/packages/zefyr/lib/src/widgets/controller.dart @@ -131,8 +131,19 @@ class ZefyrController extends ChangeNotifier { } void formatText(int index, int length, NotusAttribute attribute) { - document.format(index, length, attribute); + final change = document.format(index, length, attribute); _lastChangeSource = ChangeSource.local; + // Transform selection against the composed change and give priority to + // the change. This is needed in cases when format operation actually + // inserts data into the document (e.g. embeds). + final base = change.transformPosition(_selection.baseOffset); + final extent = + change.transformPosition(_selection.extentOffset); + final adjustedSelection = + _selection.copyWith(baseOffset: base, extentOffset: extent); + if (_selection != adjustedSelection) { + _updateSelectionSilent(adjustedSelection, source: _lastChangeSource); + } notifyListeners(); } diff --git a/packages/zefyr/lib/src/widgets/editor.dart b/packages/zefyr/lib/src/widgets/editor.dart index f7d28e8be..2f6d2d86f 100644 --- a/packages/zefyr/lib/src/widgets/editor.dart +++ b/packages/zefyr/lib/src/widgets/editor.dart @@ -10,6 +10,143 @@ import 'image.dart'; import 'theme.dart'; import 'toolbar.dart'; +class ZefyrEditorScope extends ChangeNotifier { + ZefyrEditorScope({ + @required ZefyrImageDelegate imageDelegate, + @required ZefyrController controller, + @required FocusNode focusNode, + @required FocusNode toolbarFocusNode, + }) : _controller = controller, + _imageDelegate = imageDelegate, + _focusNode = focusNode, + _toolbarFocusNode = toolbarFocusNode { + _selectionStyle = _controller.getSelectionStyle(); + _selection = _controller.selection; + _controller.addListener(_handleControllerChange); + toolbarFocusNode.addListener(_handleFocusChange); + _focusNode.addListener(_handleFocusChange); + } + + bool _disposed = false; + + ZefyrImageDelegate _imageDelegate; + ZefyrImageDelegate get imageDelegate => _imageDelegate; + + FocusNode _focusNode; + FocusNode _toolbarFocusNode; + FocusNode get toolbarFocusNode => _toolbarFocusNode; + + ZefyrController _controller; + NotusStyle get selectionStyle => _selectionStyle; + NotusStyle _selectionStyle; + TextSelection get selection => _selection; + TextSelection _selection; + + @override + void dispose() { + assert(!_disposed); + _controller.removeListener(_handleControllerChange); + _toolbarFocusNode.removeListener(_handleFocusChange); + _focusNode.removeListener(_handleFocusChange); + _disposed = true; + super.dispose(); + } + + void _updateControllerIfNeeded(ZefyrController value) { + if (_controller != value) { + _controller.removeListener(_handleControllerChange); + _controller = value; + _selectionStyle = _controller.getSelectionStyle(); + _selection = _controller.selection; + _controller.addListener(_handleControllerChange); + notifyListeners(); + } + } + + void _updateFocusNodeIfNeeded(FocusNode value) { + if (_focusNode != value) { + _focusNode.removeListener(_handleFocusChange); + _focusNode = value; + _focusNode.addListener(_handleFocusChange); + notifyListeners(); + } + } + + void _updateImageDelegateIfNeeded(ZefyrImageDelegate value) { + if (_imageDelegate != value) { + _imageDelegate = value; + notifyListeners(); + } + } + + void _handleControllerChange() { + assert(!_disposed); + final attrs = _controller.getSelectionStyle(); + final selection = _controller.selection; + if (_selectionStyle != attrs || _selection != selection) { + _selectionStyle = attrs; + _selection = _controller.selection; + notifyListeners(); + } + } + + void _handleFocusChange() { + assert(!_disposed); + if (focusOwner == FocusOwner.none && !_selection.isCollapsed) { + // Collapse selection if there is nothing focused. + _controller.updateSelection(_selection.copyWith( + baseOffset: _selection.extentOffset, + extentOffset: _selection.extentOffset, + )); + } + notifyListeners(); + } + + FocusOwner get focusOwner { + assert(!_disposed); + if (_focusNode.hasFocus) { + return FocusOwner.editor; + } else if (toolbarFocusNode.hasFocus) { + return FocusOwner.toolbar; + } else { + return FocusOwner.none; + } + } + + void updateSelection(TextSelection value, + {ChangeSource source: ChangeSource.remote}) { + assert(!_disposed); + _controller.updateSelection(value, source: source); + } + + void formatSelection(NotusAttribute value) { + assert(!_disposed); + _controller.formatSelection(value); + } + + void focus(BuildContext context) { + assert(!_disposed); + FocusScope.of(context).requestFocus(_focusNode); + } + + void hideKeyboard() { + assert(!_disposed); + _focusNode.unfocus(); + } +} + +class _ZefyrEditorScope extends InheritedWidget { + final ZefyrEditorScope scope; + + _ZefyrEditorScope({Key key, Widget child, @required this.scope}) + : super(key: key, child: child); + + @override + bool updateShouldNotify(_ZefyrEditorScope oldWidget) { + return oldWidget.scope != scope; + } +} + /// Widget for editing Zefyr documents. class ZefyrEditor extends StatefulWidget { const ZefyrEditor({ @@ -34,119 +171,46 @@ class ZefyrEditor extends StatefulWidget { final EdgeInsets padding; static ZefyrEditorScope of(BuildContext context) { - ZefyrEditorScope scope = - context.inheritFromWidgetOfExactType(ZefyrEditorScope); - return scope; + _ZefyrEditorScope widget = + context.inheritFromWidgetOfExactType(_ZefyrEditorScope); + return widget.scope; } @override _ZefyrEditorState createState() => new _ZefyrEditorState(); } -/// Inherited widget which provides access to shared state of a Zefyr editor. -class ZefyrEditorScope extends InheritedWidget { - /// Current selection style - final NotusStyle selectionStyle; - final TextSelection selection; - final FocusOwner focusOwner; - final FocusNode toolbarFocusNode; - final ZefyrImageDelegate imageDelegate; - final ZefyrController _controller; - final FocusNode _focusNode; - - ZefyrEditorScope({ - Key key, - @required Widget child, - @required this.selectionStyle, - @required this.selection, - @required this.focusOwner, - @required this.toolbarFocusNode, - @required this.imageDelegate, - @required ZefyrController controller, - @required FocusNode focusNode, - }) : _controller = controller, - _focusNode = focusNode, - super(key: key, child: child); - - void updateSelection(TextSelection value, - {ChangeSource source: ChangeSource.remote}) { - _controller.updateSelection(value, source: source); - } - - void formatSelection(NotusAttribute value) { - _controller.formatSelection(value); - } - - void focus(BuildContext context) { - FocusScope.of(context).requestFocus(_focusNode); - } - - void hideKeyboard() { - _focusNode.unfocus(); - } - - @override - bool updateShouldNotify(ZefyrEditorScope oldWidget) { - return (selectionStyle != oldWidget.selectionStyle || - selection != oldWidget.selection || - focusOwner != oldWidget.focusOwner || - imageDelegate != oldWidget.imageDelegate); - } -} - class _ZefyrEditorState extends State { final FocusNode _toolbarFocusNode = new FocusNode(); - - NotusStyle _selectionStyle; - TextSelection _selection; - FocusOwner _focusOwner; ZefyrImageDelegate _imageDelegate; - - FocusOwner getFocusOwner() { - if (widget.focusNode.hasFocus) { - return FocusOwner.editor; - } else if (_toolbarFocusNode.hasFocus) { - return FocusOwner.toolbar; - } else { - return FocusOwner.none; - } - } + ZefyrEditorScope _scope; @override void initState() { super.initState(); - _selectionStyle = widget.controller.getSelectionStyle(); - _selection = widget.controller.selection; - _focusOwner = getFocusOwner(); _imageDelegate = widget.imageDelegate ?? new ZefyrDefaultImageDelegate(); - widget.controller.addListener(_handleControllerChange); - _toolbarFocusNode.addListener(_handleFocusChange); - widget.focusNode.addListener(_handleFocusChange); + _scope = ZefyrEditorScope( + toolbarFocusNode: _toolbarFocusNode, + imageDelegate: _imageDelegate, + controller: widget.controller, + focusNode: widget.focusNode, + ); } @override void didUpdateWidget(ZefyrEditor oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode.removeListener(_handleFocusChange); - widget.focusNode.addListener(_handleFocusChange); - } - if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_handleControllerChange); - widget.controller.addListener(_handleControllerChange); - _selectionStyle = widget.controller.getSelectionStyle(); - _selection = widget.controller.selection; - } + _scope._updateControllerIfNeeded(widget.controller); + _scope._updateFocusNodeIfNeeded(widget.focusNode); if (widget.imageDelegate != oldWidget.imageDelegate) { _imageDelegate = widget.imageDelegate ?? new ZefyrDefaultImageDelegate(); + _scope._updateImageDelegateIfNeeded(_imageDelegate); } } @override void dispose() { - widget.controller.removeListener(_handleControllerChange); - widget.focusNode.removeListener(_handleFocusChange); - _toolbarFocusNode.removeListener(_handleFocusChange); + _scope.dispose(); _toolbarFocusNode.dispose(); super.dispose(); } @@ -165,8 +229,8 @@ class _ZefyrEditorState extends State { final children = []; children.add(Expanded(child: editable)); final toolbar = ZefyrToolbar( + editor: _scope, focusNode: _toolbarFocusNode, - controller: widget.controller, delegate: widget.toolbarDelegate, ); children.add(toolbar); @@ -179,40 +243,10 @@ class _ZefyrEditorState extends State { return ZefyrTheme( data: actualTheme, - child: ZefyrEditorScope( - selection: _selection, - selectionStyle: _selectionStyle, - focusOwner: _focusOwner, - toolbarFocusNode: _toolbarFocusNode, - imageDelegate: _imageDelegate, - controller: widget.controller, - focusNode: widget.focusNode, + child: _ZefyrEditorScope( + scope: _scope, child: Column(children: children), ), ); } - - void _handleControllerChange() { - final attrs = widget.controller.getSelectionStyle(); - final selection = widget.controller.selection; - if (_selectionStyle != attrs || _selection != selection) { - setState(() { - _selectionStyle = attrs; - _selection = widget.controller.selection; - }); - } - } - - void _handleFocusChange() { - setState(() { - _focusOwner = getFocusOwner(); - if (_focusOwner == FocusOwner.none && !_selection.isCollapsed) { - // Collapse selection if there is nothing focused. - widget.controller.updateSelection(_selection.copyWith( - baseOffset: _selection.extentOffset, - extentOffset: _selection.extentOffset, - )); - } - }); - } } diff --git a/packages/zefyr/lib/src/widgets/input.dart b/packages/zefyr/lib/src/widgets/input.dart index b5c797ade..214f21159 100644 --- a/packages/zefyr/lib/src/widgets/input.dart +++ b/packages/zefyr/lib/src/widgets/input.dart @@ -42,6 +42,7 @@ class InputConnectionController implements TextInputClient { obscureText: false, autocorrect: true, inputAction: TextInputAction.newline, + textCapitalization: TextCapitalization.sentences, ), )..setEditingState(value); _sentRemoteValues.add(value); diff --git a/packages/zefyr/lib/src/widgets/selection.dart b/packages/zefyr/lib/src/widgets/selection.dart index d4aa42121..d858f8b31 100644 --- a/packages/zefyr/lib/src/widgets/selection.dart +++ b/packages/zefyr/lib/src/widgets/selection.dart @@ -99,6 +99,8 @@ class _ZefyrSelectionOverlayState extends State super.initState(); _toolbarController = new AnimationController( duration: _kFadeDuration, vsync: widget.overlay); + _selection = widget.controller.selection; + widget.controller.addListener(_handleChange); } static const Duration _kFadeDuration = const Duration(milliseconds: 150); @@ -112,6 +114,10 @@ class _ZefyrSelectionOverlayState extends State _toolbarController = new AnimationController( duration: _kFadeDuration, vsync: widget.overlay); } + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_handleChange); + widget.controller.addListener(_handleChange); + } } @override @@ -124,6 +130,7 @@ class _ZefyrSelectionOverlayState extends State @override void dispose() { + widget.controller.removeListener(_handleChange); hideToolbar(); _toolbarController.dispose(); _toolbarController = null; @@ -172,6 +179,12 @@ class _ZefyrSelectionOverlayState extends State bool _didCaretTap = false; + void _handleChange() { + if (_selection != widget.controller.selection) { + _updateToolbar(); + } + } + void _updateToolbar() { if (!mounted) { return; diff --git a/packages/zefyr/lib/src/widgets/toolbar.dart b/packages/zefyr/lib/src/widgets/toolbar.dart index 02f90dd8b..8d12adf22 100644 --- a/packages/zefyr/lib/src/widgets/toolbar.dart +++ b/packages/zefyr/lib/src/widgets/toolbar.dart @@ -103,14 +103,14 @@ class ZefyrToolbar extends StatefulWidget implements PreferredSizeWidget { const ZefyrToolbar({ Key key, @required this.focusNode, - @required this.controller, + @required this.editor, this.autoHide: true, this.delegate, }) : super(key: key); final FocusNode focusNode; - final ZefyrController controller; final ZefyrToolbarDelegate delegate; + final ZefyrEditorScope editor; /// Whether to automatically hide this toolbar when editor loses focus. final bool autoHide; @@ -185,12 +185,23 @@ class ZefyrToolbarState extends State bool get hasOverlay => _overlayBuilder != null; + ZefyrEditorScope get editor => widget.editor; + + void _handleChange() { + if (_selection != editor.selection) { + _selection = editor.selection; + closeOverlay(); + } + setState(() {}); + } + @override void initState() { super.initState(); _delegate = widget.delegate ?? new _DefaultZefyrToolbarDelegate(); _overlayAnimation = new AnimationController( vsync: this, duration: Duration(milliseconds: 100)); + widget.editor.addListener(_handleChange); } @override @@ -199,22 +210,20 @@ class ZefyrToolbarState extends State if (widget.delegate != oldWidget.delegate) { _delegate = widget.delegate ?? new _DefaultZefyrToolbarDelegate(); } + if (widget.editor != oldWidget.editor) { + oldWidget.editor.removeListener(_handleChange); + widget.editor.addListener(_handleChange); + } } @override - void didChangeDependencies() { - super.didChangeDependencies(); - final editor = ZefyrEditor.of(context); - if (_selection != editor.selection) { - _selection = editor.selection; - closeOverlay(); - } + void dispose() { + widget.editor.removeListener(_handleChange); + super.dispose(); } @override Widget build(BuildContext context) { - final editor = ZefyrEditor.of(context); - if (editor.focusOwner == FocusOwner.none) { return new Container(); } diff --git a/packages/zefyr/lib/util.dart b/packages/zefyr/lib/util.dart index 07b2f4e84..7ef640e83 100644 --- a/packages/zefyr/lib/util.dart +++ b/packages/zefyr/lib/util.dart @@ -26,6 +26,11 @@ int getPositionDelta(Delta user, Delta actual) { } else if (userOp.isDelete && actualOp.isRetain) { diff += userOp.length; } else if (userOp.isRetain && actualOp.isInsert) { + if (actualOp.data.startsWith('\n') ) { + // At this point user input reached its end (retain). If a heuristic + // rule inserts a new line we should keep cursor on it's original position. + continue; + } diff += actualOp.length; } else { // TODO: this likely needs to cover more edge cases. diff --git a/packages/zefyr/pubspec.yaml b/packages/zefyr/pubspec.yaml index 631f16042..f881a2878 100644 --- a/packages/zefyr/pubspec.yaml +++ b/packages/zefyr/pubspec.yaml @@ -1,6 +1,6 @@ name: zefyr description: Clean, minimalistic and collaboration-ready rich text editor for Flutter. -version: 0.1.3 +version: 0.2.0 author: Anatoly Pulyaevskiy homepage: https://github.com/memspace/zefyr diff --git a/packages/zefyr/test/widgets/editor_test.dart b/packages/zefyr/test/widgets/editor_test.dart index 22be8a39c..375fdc5d9 100644 --- a/packages/zefyr/test/widgets/editor_test.dart +++ b/packages/zefyr/test/widgets/editor_test.dart @@ -23,6 +23,8 @@ void main() { var editor = new EditorSandBox(tester: tester, document: doc, theme: theme); await editor.tapEditor(); + // TODO: figure out why this extra pump is needed here + await tester.pumpAndSettle(); EditableRichText p = tester.widget(find.byType(EditableRichText).first); expect(p.text.children.first.style.color, Colors.red); }); diff --git a/packages/zefyr/tool/travis.sh b/packages/zefyr/tool/travis.sh index 919b0adf4..517294c8d 100755 --- a/packages/zefyr/tool/travis.sh +++ b/packages/zefyr/tool/travis.sh @@ -2,4 +2,4 @@ set -e -$TRAVIS_BUILD_DIR/flutter/bin/flutter test --coverage +$TRAVIS_BUILD_DIR/flutter/bin/flutter test