diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..62c893550 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/doc/quick_start.md b/doc/quick_start.md index b38700cef..6e6bb3176 100644 --- a/doc/quick_start.md +++ b/doc/quick_start.md @@ -12,7 +12,7 @@ Add `zefyr` package as a dependency to your `pubspec.yaml`: ```yaml dependencies: - zefyr: ^0.1.0 + zefyr: ^0.3.0 ``` And run `flutter packages get` to install. This installs both `zefyr` @@ -20,7 +20,7 @@ and `notus` packages. ### Usage -There are 3 main objects you would normally interact with in your code: +There are 4 main objects you would normally interact with in your code: * `NotusDocument`, represents a rich text document and provides high-level methods for manipulating the document's state, like @@ -30,6 +30,9 @@ There are 3 main objects you would normally interact with in your code: * `ZefyrEditor`, a Flutter widget responsible for rendering of rich text on the screen and reacting to user actions. * `ZefyrController`, ties the above two objects together. +* `ZefyrScaffold`, allows embedding Zefyr toolbar into any custom layout. + +`ZefyrEditor` depends on presence of `ZefyrScaffold` somewhere up the widget tree. Normally you would need to place `ZefyrEditor` inside of a `StatefulWidget`. Shown below is a minimal setup required to use the @@ -60,9 +63,11 @@ class MyWidgetState extends State { @override Widget build(BuildContext context) { - return ZefyrEditor( - controller: _controller, - focusNode: _focusNode, + return ZefyrScaffold( + child: ZefyrEditor( + controller: _controller, + focusNode: _focusNode, + ), ); } } diff --git a/packages/zefyr/.gitignore b/packages/zefyr/.gitignore index 0ff7b798f..febd91438 100644 --- a/packages/zefyr/.gitignore +++ b/packages/zefyr/.gitignore @@ -14,3 +14,5 @@ example/ios/.symlinks example/ios/Flutter/Generated.xcconfig doc/api/ build/ + +example/feather diff --git a/packages/zefyr/CHANGELOG.md b/packages/zefyr/CHANGELOG.md index 46f22dd7c..a18423601 100644 --- a/packages/zefyr/CHANGELOG.md +++ b/packages/zefyr/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.3.0 + +This version introduces new widget `ZefyrScaffold` which allows embedding Zefyr in custom +layouts, like forms with multiple input fields. + +It is now required to always wrap `ZefyrEditor` with an instance of this new widget. See examples +and readme for more details. + +There is also new `ZefyrField` widget which integrates Zefyr with material design decorations. + +* Breaking change: `ZefyrEditor` requires an ancestor `ZefyrScaffold`. +* Upgraded to `url_launcher` version 4.0.0. +* Exposed `ZefyrEditor.physics` property to allow customization of `ScrollPhysics`. +* Added basic `ZefyrField` widget with material design decorations. + ## 0.2.0 * Breaking change: `ZefyrImageDelegate.createImageProvider` replaced with diff --git a/packages/zefyr/analysis_options.yaml b/packages/zefyr/analysis_options.yaml index 0f92e6543..cf554432c 100644 --- a/packages/zefyr/analysis_options.yaml +++ b/packages/zefyr/analysis_options.yaml @@ -1,6 +1,6 @@ analyzer: language: - enableSuperMixins: true +# enableSuperMixins: true # Lint rules and documentation, see http://dart-lang.github.io/linter/lints linter: diff --git a/packages/zefyr/example/lib/main.dart b/packages/zefyr/example/lib/main.dart index ef157bb7a..112764c01 100644 --- a/packages/zefyr/example/lib/main.dart +++ b/packages/zefyr/example/lib/main.dart @@ -1,125 +1,67 @@ // Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:quill_delta/quill_delta.dart'; -import 'package:zefyr/zefyr.dart'; +import 'src/form.dart'; +import 'src/full_page.dart'; void main() { runApp(new ZefyrApp()); } -class ZefyrLogo extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Ze'), - FlutterLogo(size: 24.0), - Text('yr'), - ], - ); - } -} - class ZefyrApp extends StatelessWidget { @override Widget build(BuildContext context) { - return new MaterialApp( + return MaterialApp( debugShowCheckedModeBanner: false, title: 'Zefyr Editor', - theme: new ThemeData(primarySwatch: Colors.cyan), - home: new MyHomePage(), + theme: ThemeData(primarySwatch: Colors.cyan), + home: HomePage(), + routes: { + "/fullPage": buildFullPage, + "/form": buildFormPage, + }, ); } -} - -class MyHomePage extends StatefulWidget { - @override - _MyHomePageState createState() => new _MyHomePageState(); -} -final doc = - r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://images/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":' - r'".\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/quick_start.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data Format and Document Model","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/data_and_document.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style Attributes","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/attr' - r'ibutes.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic Rules","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/heuristics.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"FAQ","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/faq.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and fle' - r'xibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nMarkdown inspired semantics"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Ever needed to have a heading line inside of a quote block, like this:\nI’m a Markdown heading"},{"insert":"\n","attributes":{"block":"quote","heading":3}},{"insert":"And I’m a regular paragraph"},{"insert":"\n","attributes":{"block":"quote"}},{"insert":"Code blocks"},{"insert":"\n","attributes":{"headin' - r'g":2}},{"insert":"Of course:\nimport ‘package:flutter/material.dart’;"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"import ‘package:zefyr/zefyr.dart’;"},{"insert":"\n\n","attributes":{"block":"code"}},{"insert":"void main() {"},{"insert":"\n","attributes":{"block":"code"}},{"insert":" runApp(MyZefyrApp());"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"}"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"\n\n\n"}]'; + Widget buildFullPage(BuildContext context) { + return FullPageEditorScreen(); + } -Delta getDelta() { - return Delta.fromJson(json.decode(doc)); + Widget buildFormPage(BuildContext context) { + return FormEmbeddedScreen(); + } } -class _MyHomePageState extends State { - final ZefyrController _controller = - ZefyrController(NotusDocument.fromDelta(getDelta())); - final FocusNode _focusNode = new FocusNode(); - bool _editing = false; - +class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = new ZefyrThemeData( - toolbarTheme: ZefyrToolbarTheme.fallback(context).copyWith( - color: Colors.grey.shade800, - toggleColor: Colors.grey.shade900, - iconColor: Colors.white, - disabledIconColor: Colors.grey.shade500, - ), - ); - - final done = _editing - ? [new FlatButton(onPressed: _stopEditing, child: Text('DONE'))] - : [new FlatButton(onPressed: _startEditing, child: Text('EDIT'))]; + final nav = Navigator.of(context); return Scaffold( - resizeToAvoidBottomPadding: true, appBar: AppBar( elevation: 1.0, backgroundColor: Colors.grey.shade200, brightness: Brightness.light, title: ZefyrLogo(), - actions: done, ), - body: ZefyrTheme( - data: theme, - child: ZefyrEditor( - controller: _controller, - focusNode: _focusNode, - enabled: _editing, - imageDelegate: new CustomImageDelegate(), - ), + body: Column( + children: [ + Expanded(child: Container()), + FlatButton( + onPressed: () => nav.pushNamed('/fullPage'), + child: Text('Full page editor'), + color: Colors.lightBlue, + textColor: Colors.white, + ), + FlatButton( + onPressed: () => nav.pushNamed('/form'), + child: Text('Embedded in a form'), + color: Colors.lightBlue, + textColor: Colors.white, + ), + Expanded(child: Container()), + ], ), ); } - - void _startEditing() { - setState(() { - _editing = true; - }); - } - - void _stopEditing() { - setState(() { - _editing = false; - }); - } -} - -/// Custom image delegate used by this example to load image from application -/// assets. -/// -/// Default image delegate only supports [FileImage]s. -class CustomImageDelegate extends ZefyrDefaultImageDelegate { - @override - Widget buildImage(BuildContext context, String imageSource) { - // We use custom "asset" scheme to distinguish asset images from other files. - if (imageSource.startsWith('asset://')) { - final asset = new AssetImage(imageSource.replaceFirst('asset://', '')); - return new Image(image: asset); - } else { - return super.buildImage(context, imageSource); - } - } } diff --git a/packages/zefyr/example/lib/src/form.dart b/packages/zefyr/example/lib/src/form.dart new file mode 100644 index 000000000..50c45652c --- /dev/null +++ b/packages/zefyr/example/lib/src/form.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:zefyr/zefyr.dart'; + +import 'full_page.dart'; + +class FormEmbeddedScreen extends StatefulWidget { + @override + _FormEmbeddedScreenState createState() => _FormEmbeddedScreenState(); +} + +class _FormEmbeddedScreenState extends State { + final ZefyrController _controller = ZefyrController(NotusDocument()); + final FocusNode _focusNode = new FocusNode(); + + @override + Widget build(BuildContext context) { + final form = ListView( + children: [ + TextField(decoration: InputDecoration(labelText: 'Name')), + buildEditor(), + TextField(decoration: InputDecoration(labelText: 'Email')), + ], + ); + + return Scaffold( + resizeToAvoidBottomPadding: true, + appBar: AppBar( + elevation: 1.0, + backgroundColor: Colors.grey.shade200, + brightness: Brightness.light, + title: ZefyrLogo(), + ), + body: ZefyrScaffold( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: form, + ), + ), + ); + } + + Widget buildEditor() { + final theme = new ZefyrThemeData( + toolbarTheme: ZefyrToolbarTheme.fallback(context).copyWith( + color: Colors.grey.shade800, + toggleColor: Colors.grey.shade900, + iconColor: Colors.white, + disabledIconColor: Colors.grey.shade500, + ), + ); + + return ZefyrTheme( + data: theme, + child: ZefyrField( + height: 200.0, + decoration: InputDecoration(labelText: 'Description'), + controller: _controller, + focusNode: _focusNode, + autofocus: false, + imageDelegate: new CustomImageDelegate(), + physics: ClampingScrollPhysics(), + ), + ); + } +} diff --git a/packages/zefyr/example/lib/src/full_page.dart b/packages/zefyr/example/lib/src/full_page.dart new file mode 100644 index 000000000..8115230ec --- /dev/null +++ b/packages/zefyr/example/lib/src/full_page.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:quill_delta/quill_delta.dart'; +import 'package:zefyr/zefyr.dart'; + +class ZefyrLogo extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Ze'), + FlutterLogo(size: 24.0), + Text('yr'), + ], + ); + } +} + +class FullPageEditorScreen extends StatefulWidget { + @override + _FullPageEditorScreenState createState() => new _FullPageEditorScreenState(); +} + +final doc = + r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://images/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":' + r'".\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/quick_start.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data Format and Document Model","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/data_and_document.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style Attributes","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/attr' + r'ibutes.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic Rules","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/heuristics.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"FAQ","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/faq.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and fle' + r'xibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nMarkdown inspired semantics"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Ever needed to have a heading line inside of a quote block, like this:\nI’m a Markdown heading"},{"insert":"\n","attributes":{"block":"quote","heading":3}},{"insert":"And I’m a regular paragraph"},{"insert":"\n","attributes":{"block":"quote"}},{"insert":"Code blocks"},{"insert":"\n","attributes":{"headin' + r'g":2}},{"insert":"Of course:\nimport ‘package:flutter/material.dart’;"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"import ‘package:zefyr/zefyr.dart’;"},{"insert":"\n\n","attributes":{"block":"code"}},{"insert":"void main() {"},{"insert":"\n","attributes":{"block":"code"}},{"insert":" runApp(MyZefyrApp());"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"}"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"\n\n\n"}]'; + +Delta getDelta() { + return Delta.fromJson(json.decode(doc)); +} + +class _FullPageEditorScreenState extends State { + final ZefyrController _controller = + ZefyrController(NotusDocument.fromDelta(getDelta())); + final FocusNode _focusNode = new FocusNode(); + bool _editing = false; + + @override + Widget build(BuildContext context) { + final theme = new ZefyrThemeData( + toolbarTheme: ZefyrToolbarTheme.fallback(context).copyWith( + color: Colors.grey.shade800, + toggleColor: Colors.grey.shade900, + iconColor: Colors.white, + disabledIconColor: Colors.grey.shade500, + ), + ); + + final done = _editing + ? [new FlatButton(onPressed: _stopEditing, child: Text('DONE'))] + : [new FlatButton(onPressed: _startEditing, child: Text('EDIT'))]; + return Scaffold( + resizeToAvoidBottomPadding: true, + appBar: AppBar( + elevation: 1.0, + backgroundColor: Colors.grey.shade200, + brightness: Brightness.light, + title: ZefyrLogo(), + actions: done, + ), + body: ZefyrScaffold( + child: ZefyrTheme( + data: theme, + child: ZefyrEditor( + controller: _controller, + focusNode: _focusNode, + enabled: _editing, + imageDelegate: new CustomImageDelegate(), + ), + ), + ), + ); + } + + void _startEditing() { + setState(() { + _editing = true; + }); + } + + void _stopEditing() { + setState(() { + _editing = false; + }); + } +} + +/// Custom image delegate used by this example to load image from application +/// assets. +/// +/// Default image delegate only supports [FileImage]s. +class CustomImageDelegate extends ZefyrDefaultImageDelegate { + @override + Widget buildImage(BuildContext context, String imageSource) { + // We use custom "asset" scheme to distinguish asset images from other files. + if (imageSource.startsWith('asset://')) { + final asset = new AssetImage(imageSource.replaceFirst('asset://', '')); + return new Image(image: asset); + } else { + return super.buildImage(context, imageSource); + } + } +} diff --git a/packages/zefyr/lib/src/widgets/buttons.dart b/packages/zefyr/lib/src/widgets/buttons.dart index b96bc0a08..d762cba3f 100644 --- a/packages/zefyr/lib/src/widgets/buttons.dart +++ b/packages/zefyr/lib/src/widgets/buttons.dart @@ -300,9 +300,11 @@ class LinkButton extends StatefulWidget { } class _LinkButtonState extends State { - final TextEditingController _inputController = new TextEditingController(); + final TextEditingController _inputController = TextEditingController(); Key _inputKey; bool _formatError = false; + ZefyrEditorScope _editor; + bool get isEditing => _inputKey != null; @override @@ -376,7 +378,7 @@ class _LinkButtonState extends State { _inputController.text = ''; _inputController.removeListener(_handleInputChange); toolbar.markNeedsRebuild(); - toolbar.editor.focus(context); + toolbar.editor.focus(); } }); } @@ -388,7 +390,7 @@ class _LinkButtonState extends State { _inputKey = null; _inputController.text = ''; _inputController.removeListener(_handleInputChange); - editor.focus(context); + editor.focus(); }); } } @@ -437,7 +439,6 @@ class _LinkButtonState extends State { : _LinkInput( key: _inputKey, controller: _inputController, - focusNode: toolbar.editor.toolbarFocusNode, formatError: _formatError, ); final items = [Expanded(child: body)]; @@ -474,16 +475,13 @@ class _LinkButtonState extends State { } class _LinkInput extends StatefulWidget { - final FocusNode focusNode; final TextEditingController controller; final bool formatError; - const _LinkInput({ - Key key, - @required this.focusNode, - @required this.controller, - this.formatError: false, - }) : super(key: key); + const _LinkInput( + {Key key, @required this.controller, this.formatError: false}) + : super(key: key); + @override _LinkInputState createState() { return new _LinkInputState(); @@ -491,21 +489,38 @@ class _LinkInput extends StatefulWidget { } class _LinkInputState extends State<_LinkInput> { + final FocusNode _focusNode = FocusNode(); + + ZefyrEditorScope _editor; bool _didAutoFocus = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_didAutoFocus) { - FocusScope.of(context).requestFocus(widget.focusNode); + FocusScope.of(context).requestFocus(_focusNode); _didAutoFocus = true; } + + final toolbar = ZefyrToolbar.of(context); + + if (_editor != toolbar.editor) { + _editor?.setToolbarFocusNode(null); + _editor = toolbar.editor; + _editor.setToolbarFocusNode(_focusNode); + } } @override - Widget build(BuildContext context) { - FocusScope.of(context).reparentIfNeeded(widget.focusNode); + void dispose() { + _editor?.setToolbarFocusNode(null); + _focusNode.dispose(); + _editor = null; + super.dispose(); + } + @override + Widget build(BuildContext context) { final theme = Theme.of(context); final toolbarTheme = ZefyrTheme.of(context).toolbarTheme; final color = @@ -514,15 +529,16 @@ class _LinkInputState extends State<_LinkInput> { return TextField( style: style, keyboardType: TextInputType.url, - focusNode: widget.focusNode, + focusNode: _focusNode, controller: widget.controller, autofocus: true, decoration: new InputDecoration( - hintText: 'https://', - filled: true, - fillColor: toolbarTheme.color, - border: InputBorder.none, - contentPadding: const EdgeInsets.all(10.0)), + hintText: 'https://', + filled: true, + fillColor: toolbarTheme.color, + border: InputBorder.none, + contentPadding: const EdgeInsets.all(10.0), + ), ); } } diff --git a/packages/zefyr/lib/src/widgets/editable_text.dart b/packages/zefyr/lib/src/widgets/editable_text.dart index c3ef396dd..b3b671ac1 100644 --- a/packages/zefyr/lib/src/widgets/editable_text.dart +++ b/packages/zefyr/lib/src/widgets/editable_text.dart @@ -36,6 +36,7 @@ class ZefyrEditableText extends StatefulWidget { this.autofocus: true, this.enabled: true, this.padding: const EdgeInsets.symmetric(horizontal: 16.0), + this.physics, }) : super(key: key); final ZefyrController controller; @@ -43,6 +44,7 @@ class ZefyrEditableText extends StatefulWidget { final ZefyrImageDelegate imageDelegate; final bool autofocus; final bool enabled; + final ScrollPhysics physics; /// Padding around editable area. final EdgeInsets padding; @@ -132,8 +134,7 @@ class _ZefyrEditableTextState extends State body = new Padding(padding: widget.padding, child: body); } final scrollable = SingleChildScrollView( - padding: EdgeInsets.only(top: 16.0), - physics: AlwaysScrollableScrollPhysics(), + physics: widget.physics, controller: _scrollController, child: body, ); diff --git a/packages/zefyr/lib/src/widgets/editor.dart b/packages/zefyr/lib/src/widgets/editor.dart index 2f6d2d86f..23ad357ab 100644 --- a/packages/zefyr/lib/src/widgets/editor.dart +++ b/packages/zefyr/lib/src/widgets/editor.dart @@ -7,6 +7,7 @@ import 'package:notus/notus.dart'; import 'controller.dart'; import 'editable_text.dart'; import 'image.dart'; +import 'scaffold.dart'; import 'theme.dart'; import 'toolbar.dart'; @@ -15,15 +16,14 @@ class ZefyrEditorScope extends ChangeNotifier { @required ZefyrImageDelegate imageDelegate, @required ZefyrController controller, @required FocusNode focusNode, - @required FocusNode toolbarFocusNode, + @required FocusScopeNode focusScope, }) : _controller = controller, _imageDelegate = imageDelegate, - _focusNode = focusNode, - _toolbarFocusNode = toolbarFocusNode { + _focusScope = focusScope, + _focusNode = focusNode { _selectionStyle = _controller.getSelectionStyle(); _selection = _controller.selection; _controller.addListener(_handleControllerChange); - toolbarFocusNode.addListener(_handleFocusChange); _focusNode.addListener(_handleFocusChange); } @@ -32,9 +32,8 @@ class ZefyrEditorScope extends ChangeNotifier { ZefyrImageDelegate _imageDelegate; ZefyrImageDelegate get imageDelegate => _imageDelegate; + FocusScopeNode _focusScope; FocusNode _focusNode; - FocusNode _toolbarFocusNode; - FocusNode get toolbarFocusNode => _toolbarFocusNode; ZefyrController _controller; NotusStyle get selectionStyle => _selectionStyle; @@ -46,7 +45,6 @@ class ZefyrEditorScope extends ChangeNotifier { void dispose() { assert(!_disposed); _controller.removeListener(_handleControllerChange); - _toolbarFocusNode.removeListener(_handleFocusChange); _focusNode.removeListener(_handleFocusChange); _disposed = true; super.dispose(); @@ -102,11 +100,24 @@ class ZefyrEditorScope extends ChangeNotifier { notifyListeners(); } + FocusNode _toolbarFocusNode; + + void setToolbarFocusNode(FocusNode node) { + assert(!_disposed || node == null); + if (_toolbarFocusNode != node) { + _toolbarFocusNode?.removeListener(_handleFocusChange); + _toolbarFocusNode = node; + _toolbarFocusNode?.addListener(_handleFocusChange); + // We do not notify listeners here because it will happen when + // focus state changes, see [_handleFocusChange]. + } + } + FocusOwner get focusOwner { assert(!_disposed); if (_focusNode.hasFocus) { return FocusOwner.editor; - } else if (toolbarFocusNode.hasFocus) { + } else if (_toolbarFocusNode?.hasFocus == true) { return FocusOwner.toolbar; } else { return FocusOwner.none; @@ -124,9 +135,9 @@ class ZefyrEditorScope extends ChangeNotifier { _controller.formatSelection(value); } - void focus(BuildContext context) { + void focus() { assert(!_disposed); - FocusScope.of(context).requestFocus(_focusNode); + _focusScope.requestFocus(_focusNode); } void hideKeyboard() { @@ -158,6 +169,7 @@ class ZefyrEditor extends StatefulWidget { this.padding: const EdgeInsets.symmetric(horizontal: 16.0), this.toolbarDelegate, this.imageDelegate, + this.physics, }) : super(key: key); final ZefyrController controller; @@ -166,6 +178,7 @@ class ZefyrEditor extends StatefulWidget { final bool enabled; final ZefyrToolbarDelegate toolbarDelegate; final ZefyrImageDelegate imageDelegate; + final ScrollPhysics physics; /// Padding around editable area. final EdgeInsets padding; @@ -181,20 +194,53 @@ class ZefyrEditor extends StatefulWidget { } class _ZefyrEditorState extends State { - final FocusNode _toolbarFocusNode = new FocusNode(); ZefyrImageDelegate _imageDelegate; ZefyrEditorScope _scope; + ZefyrThemeData _themeData; + GlobalKey _toolbarKey; + ZefyrScaffoldState _scaffold; + + bool get hasToolbar => _toolbarKey != null; + + void showToolbar() { + assert(_toolbarKey == null); + _toolbarKey = GlobalKey(); + _scaffold.showToolbar(buildToolbar); + } + + void hideToolbar() { + if (_toolbarKey == null) return; + _scaffold.hideToolbar(); + _toolbarKey = null; + } + + Widget buildToolbar(BuildContext) { + return ZefyrTheme( + data: _themeData, + child: ZefyrToolbar( + key: _toolbarKey, + editor: _scope, + delegate: widget.toolbarDelegate, + ), + ); + } + + void _handleChange() { + if (_scope.focusOwner == FocusOwner.none) { + hideToolbar(); + } else if (!hasToolbar) { + showToolbar(); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + _toolbarKey?.currentState?.markNeedsRebuild(); + }); + } + } @override void initState() { super.initState(); _imageDelegate = widget.imageDelegate ?? new ZefyrDefaultImageDelegate(); - _scope = ZefyrEditorScope( - toolbarFocusNode: _toolbarFocusNode, - imageDelegate: _imageDelegate, - controller: widget.controller, - focusNode: widget.focusNode, - ); } @override @@ -208,10 +254,44 @@ class _ZefyrEditorState extends State { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parentTheme = ZefyrTheme.of(context, nullOk: true); + final fallbackTheme = ZefyrThemeData.fallback(context); + _themeData = (parentTheme != null) + ? fallbackTheme.merge(parentTheme) + : fallbackTheme; + + if (_scope == null) { + _scope = ZefyrEditorScope( + imageDelegate: _imageDelegate, + controller: widget.controller, + focusNode: widget.focusNode, + focusScope: FocusScope.of(context), + ); + _scope.addListener(_handleChange); + } else { + final focusScope = FocusScope.of(context); + if (focusScope != _scope._focusScope) { + _scope._focusScope = focusScope; + } + } + + final scaffold = ZefyrScaffold.of(context); + if (_scaffold != scaffold) { + bool didHaveToolbar = hasToolbar; + hideToolbar(); + _scaffold = scaffold; + if (didHaveToolbar) showToolbar(); + } + } + @override void dispose() { + hideToolbar(); + _scope.removeListener(_handleChange); _scope.dispose(); - _toolbarFocusNode.dispose(); super.dispose(); } @@ -224,28 +304,14 @@ class _ZefyrEditorState extends State { autofocus: widget.autofocus, enabled: widget.enabled, padding: widget.padding, + physics: widget.physics, ); - final children = []; - children.add(Expanded(child: editable)); - final toolbar = ZefyrToolbar( - editor: _scope, - focusNode: _toolbarFocusNode, - delegate: widget.toolbarDelegate, - ); - children.add(toolbar); - - final parentTheme = ZefyrTheme.of(context, nullOk: true); - final fallbackTheme = ZefyrThemeData.fallback(context); - final actualTheme = (parentTheme != null) - ? fallbackTheme.merge(parentTheme) - : fallbackTheme; - return ZefyrTheme( - data: actualTheme, + data: _themeData, child: _ZefyrEditorScope( scope: _scope, - child: Column(children: children), + child: editable, ), ); } diff --git a/packages/zefyr/lib/src/widgets/field.dart b/packages/zefyr/lib/src/widgets/field.dart new file mode 100644 index 000000000..d23f3ca78 --- /dev/null +++ b/packages/zefyr/lib/src/widgets/field.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'controller.dart'; +import 'editor.dart'; +import 'image.dart'; +import 'toolbar.dart'; + +/// Zefyr editor with material design decorations. +class ZefyrField extends StatefulWidget { + /// Decoration to paint around this editor. + final InputDecoration decoration; + + /// Height of this editor field. + final double height; + final ZefyrController controller; + final FocusNode focusNode; + final bool autofocus; + final bool enabled; + final ZefyrToolbarDelegate toolbarDelegate; + final ZefyrImageDelegate imageDelegate; + final ScrollPhysics physics; + + const ZefyrField({ + Key key, + this.decoration, + this.height, + this.controller, + this.focusNode, + this.autofocus: false, + this.enabled, + this.toolbarDelegate, + this.imageDelegate, + this.physics, + }) : super(key: key); + + @override + _ZefyrFieldState createState() => _ZefyrFieldState(); +} + +class _ZefyrFieldState extends State { + @override + Widget build(BuildContext context) { + Widget child = ZefyrEditor( + padding: EdgeInsets.symmetric(vertical: 6.0), + controller: widget.controller, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + enabled: widget.enabled ?? true, + toolbarDelegate: widget.toolbarDelegate, + imageDelegate: widget.imageDelegate, + physics: widget.physics, + ); + + if (widget.height != null) { + child = ConstrainedBox( + constraints: BoxConstraints.tightFor(height: widget.height), + child: child, + ); + } + + return AnimatedBuilder( + animation: + Listenable.merge([widget.focusNode, widget.controller]), + builder: (BuildContext context, Widget child) { + return InputDecorator( + decoration: _getEffectiveDecoration(), + isFocused: widget.focusNode.hasFocus, + isEmpty: widget.controller.document.length == 1, + child: child, + ); + }, + child: child, + ); + } + + InputDecoration _getEffectiveDecoration() { + final InputDecoration effectiveDecoration = + (widget.decoration ?? const InputDecoration()) + .applyDefaults(Theme.of(context).inputDecorationTheme) + .copyWith( + enabled: widget.enabled ?? true, + ); + + return effectiveDecoration; + } +} diff --git a/packages/zefyr/lib/src/widgets/scaffold.dart b/packages/zefyr/lib/src/widgets/scaffold.dart new file mode 100644 index 000000000..f711070e5 --- /dev/null +++ b/packages/zefyr/lib/src/widgets/scaffold.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class ZefyrScaffold extends StatefulWidget { + final Widget child; + + const ZefyrScaffold({Key key, this.child}) : super(key: key); + + static ZefyrScaffoldState of(BuildContext context) { + final _ZefyrScaffoldAccess widget = + context.inheritFromWidgetOfExactType(_ZefyrScaffoldAccess); + return widget.scaffold; + } + + @override + ZefyrScaffoldState createState() => ZefyrScaffoldState(); +} + +class ZefyrScaffoldState extends State { + WidgetBuilder _toolbarBuilder; + + void showToolbar(WidgetBuilder builder) { + setState(() { + _toolbarBuilder = builder; + }); + } + + void hideToolbar() { + if (_toolbarBuilder != null) { + setState(() { + _toolbarBuilder = null; + }); + } + } + + @override + Widget build(BuildContext context) { + final toolbar = + (_toolbarBuilder == null) ? Container() : _toolbarBuilder(context); + return _ZefyrScaffoldAccess( + scaffold: this, + child: Column( + children: [ + Expanded(child: widget.child), + toolbar, + ], + ), + ); + } +} + +class _ZefyrScaffoldAccess extends InheritedWidget { + final ZefyrScaffoldState scaffold; + + _ZefyrScaffoldAccess({Widget child, this.scaffold}) : super(child: child); + + @override + bool updateShouldNotify(_ZefyrScaffoldAccess oldWidget) { + return oldWidget.scaffold != scaffold; + } +} diff --git a/packages/zefyr/lib/src/widgets/selection.dart b/packages/zefyr/lib/src/widgets/selection.dart index d858f8b31..667f1a667 100644 --- a/packages/zefyr/lib/src/widgets/selection.dart +++ b/packages/zefyr/lib/src/widgets/selection.dart @@ -66,6 +66,7 @@ class _ZefyrSelectionOverlayState extends State @override void hideToolbar() { + _didCaretTap = false; // reset double tap. _toolbar?.remove(); _toolbar = null; _toolbarController.stop(); @@ -82,7 +83,6 @@ class _ZefyrSelectionOverlayState extends State editable: editable, controls: widget.controls, delegate: this, - visible: true, ), ), ); @@ -99,8 +99,6 @@ 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); @@ -114,23 +112,24 @@ 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 void didChangeDependencies() { super.didChangeDependencies(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateToolbar(); - }); + final editor = ZefyrEditor.of(context); + if (_editor != editor) { + _editor?.removeListener(_handleChange); + _editor = editor; + _editor.addListener(_handleChange); + _selection = _editor.selection; + _focusOwner = _editor.focusOwner; + } } @override void dispose() { - widget.controller.removeListener(_handleChange); + _editor.removeListener(_handleChange); hideToolbar(); _toolbarController.dispose(); _toolbarController = null; @@ -175,12 +174,14 @@ class _ZefyrSelectionOverlayState extends State OverlayEntry _toolbar; AnimationController _toolbarController; + ZefyrEditorScope _editor; TextSelection _selection; + FocusOwner _focusOwner; bool _didCaretTap = false; void _handleChange() { - if (_selection != widget.controller.selection) { + if (_selection != _editor.selection || _focusOwner != _editor.focusOwner) { _updateToolbar(); } } @@ -209,6 +210,7 @@ class _ZefyrSelectionOverlayState extends State } } _selection = selection; + _focusOwner = focusOwner; }); } @@ -423,13 +425,11 @@ class _SelectionToolbar extends StatefulWidget { @required this.editable, @required this.controls, @required this.delegate, - @required this.visible, }) : super(key: key); final ZefyrEditableTextScope editable; final TextSelectionControls controls; final TextSelectionDelegate delegate; - final bool visible; @override _SelectionToolbarState createState() => new _SelectionToolbarState(); diff --git a/packages/zefyr/lib/src/widgets/theme.dart b/packages/zefyr/lib/src/widgets/theme.dart index 832042a1d..d775dbef2 100644 --- a/packages/zefyr/lib/src/widgets/theme.dart +++ b/packages/zefyr/lib/src/widgets/theme.dart @@ -172,7 +172,7 @@ class HeadingTheme { height: 1.25, fontWeight: FontWeight.w600, ), - padding: EdgeInsets.only(bottom: 16.0), + padding: EdgeInsets.only(top: 16.0, bottom: 16.0), ), level2: StyleTheme( textStyle: TextStyle( diff --git a/packages/zefyr/lib/src/widgets/toolbar.dart b/packages/zefyr/lib/src/widgets/toolbar.dart index 8d12adf22..7490c22d7 100644 --- a/packages/zefyr/lib/src/widgets/toolbar.dart +++ b/packages/zefyr/lib/src/widgets/toolbar.dart @@ -8,7 +8,6 @@ import 'package:flutter/material.dart'; import 'package:notus/notus.dart'; import 'buttons.dart'; -import 'controller.dart'; import 'editor.dart'; import 'theme.dart'; @@ -102,13 +101,11 @@ class ZefyrToolbar extends StatefulWidget implements PreferredSizeWidget { const ZefyrToolbar({ Key key, - @required this.focusNode, @required this.editor, this.autoHide: true, this.delegate, }) : super(key: key); - final FocusNode focusNode; final ZefyrToolbarDelegate delegate; final ZefyrEditorScope editor; @@ -153,7 +150,12 @@ class ZefyrToolbarState extends State TextSelection _selection; void markNeedsRebuild() { - setState(() {}); + setState(() { + if (_selection != editor.selection) { + _selection = editor.selection; + closeOverlay(); + } + }); } Widget buildButton(BuildContext context, ZefyrToolbarAction action, @@ -187,21 +189,13 @@ class ZefyrToolbarState extends State 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); + _selection = editor.selection; } @override @@ -210,24 +204,16 @@ 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 dispose() { - widget.editor.removeListener(_handleChange); + _overlayAnimation.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - if (editor.focusOwner == FocusOwner.none) { - return new Container(); - } - final layers = []; // Must set unique key for the toolbar to prevent it from reconstructing @@ -253,7 +239,7 @@ class ZefyrToolbarState extends State final constraints = BoxConstraints.tightFor(height: ZefyrToolbar.kToolbarHeight); - return new _ZefyrToolbarScope( + return _ZefyrToolbarScope( toolbar: this, child: Container( constraints: constraints, diff --git a/packages/zefyr/lib/zefyr.dart b/packages/zefyr/lib/zefyr.dart index 335deded3..db08132c6 100644 --- a/packages/zefyr/lib/zefyr.dart +++ b/packages/zefyr/lib/zefyr.dart @@ -15,12 +15,13 @@ export 'src/widgets/common.dart'; export 'src/widgets/controller.dart'; export 'src/widgets/editable_text.dart'; export 'src/widgets/editor.dart'; +export 'src/widgets/field.dart'; export 'src/widgets/horizontal_rule.dart'; export 'src/widgets/image.dart'; export 'src/widgets/list.dart'; export 'src/widgets/paragraph.dart'; export 'src/widgets/quote.dart'; +export 'src/widgets/scaffold.dart'; export 'src/widgets/selection.dart' hide SelectionHandleDriver; export 'src/widgets/theme.dart'; export 'src/widgets/toolbar.dart'; -//export 'src/widgets/render_context.dart'; diff --git a/packages/zefyr/pubspec.yaml b/packages/zefyr/pubspec.yaml index f881a2878..49925df04 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.2.0 +version: 0.3.0 author: Anatoly Pulyaevskiy homepage: https://github.com/memspace/zefyr @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter collection: ^1.14.6 - url_launcher: ^3.0.0 + url_launcher: ^4.0.0 image_picker: ^0.4.5 quill_delta: ^1.0.0-dev.1.0 notus: ^0.1.0 diff --git a/packages/zefyr/test/testing.dart b/packages/zefyr/test/testing.dart index c45cc82f3..58861ae4d 100644 --- a/packages/zefyr/test/testing.dart +++ b/packages/zefyr/test/testing.dart @@ -30,7 +30,9 @@ class EditorSandBox { if (theme != null) { widget = ZefyrTheme(data: theme, child: widget); } - widget = MaterialApp(home: widget); + widget = MaterialApp( + home: ZefyrScaffold(child: widget), + ); return EditorSandBox._(tester, focusNode, document, controller, widget); } diff --git a/packages/zefyr/test/widgets/buttons_test.dart b/packages/zefyr/test/widgets/buttons_test.dart index 9adc37694..af7fc1d59 100644 --- a/packages/zefyr/test/widgets/buttons_test.dart +++ b/packages/zefyr/test/widgets/buttons_test.dart @@ -104,6 +104,7 @@ void main() { await tester .tap(find.widgetWithText(GestureDetector, 'Tap to edit link')); await tester.pumpAndSettle(); + expect(editor.focusNode.hasFocus, isFalse); await editor.updateSelection(base: 10, extent: 10); expect(find.byIcon(Icons.link_off), findsNothing); }); @@ -117,8 +118,8 @@ void main() { await tester .tap(find.widgetWithText(GestureDetector, 'Tap to edit link')); await tester.pumpAndSettle(); - // TODO: figure out why below finder finds 2 instances of TextField - expect(find.widgetWithText(TextField, 'https://'), findsWidgets); + expect(find.byType(TextField), findsOneWidget); + await tester.enterText(find.widgetWithText(TextField, 'https://').first, 'https://github.com'); await tester.pumpAndSettle(); diff --git a/packages/zefyr/test/widgets/image_test.dart b/packages/zefyr/test/widgets/image_test.dart index ff73b7ee4..46234814f 100644 --- a/packages/zefyr/test/widgets/image_test.dart +++ b/packages/zefyr/test/widgets/image_test.dart @@ -75,7 +75,7 @@ void main() { expect(editor.selection.extentOffset, embed.documentOffset); }); - testWidgets('tap right side of horizontal rule puts caret after it', + testWidgets('tap right side of image puts caret after it', (tester) async { final editor = new EditorSandBox(tester: tester); await editor.tapEditor(); diff --git a/packages/zefyr/test/widgets/render_context_test.dart b/packages/zefyr/test/widgets/render_context_test.dart index 6b681dafc..1dcb8e293 100644 --- a/packages/zefyr/test/widgets/render_context_test.dart +++ b/packages/zefyr/test/widgets/render_context_test.dart @@ -52,12 +52,14 @@ void main() { }); testWidgets('notifyListeners is delayed to next frame', (tester) async { - var focusNode = new FocusNode(); - var controller = new ZefyrController(new NotusDocument()); - var widget = new MaterialApp( - home: new ZefyrEditor( - controller: controller, - focusNode: focusNode, + var focusNode = FocusNode(); + var controller = ZefyrController(new NotusDocument()); + var widget = MaterialApp( + home: ZefyrScaffold( + child: ZefyrEditor( + controller: controller, + focusNode: focusNode, + ), ), ); await tester.pumpWidget(widget); diff --git a/packages/zefyr/test/widgets/selection_test.dart b/packages/zefyr/test/widgets/selection_test.dart index 562153bb6..05d535461 100644 --- a/packages/zefyr/test/widgets/selection_test.dart +++ b/packages/zefyr/test/widgets/selection_test.dart @@ -65,7 +65,7 @@ void main() { RenderBox renderObject = tester.firstRenderObject(find.byType(ZefyrEditableText)); var offset = renderObject.localToGlobal(Offset.zero); - offset += Offset(50.0, renderObject.size.height - 5.0); + offset += Offset(50.0, renderObject.size.height - 500.0); await tester.tapAt(offset); await tester.pumpAndSettle(); expect(editor.controller.selection.isCollapsed, isTrue);