diff --git a/lib/routers/router_handler.dart b/lib/routers/router_handler.dart index 8f282c18..b0555b56 100644 --- a/lib/routers/router_handler.dart +++ b/lib/routers/router_handler.dart @@ -11,6 +11,7 @@ import 'package:flutter_go/model/user_info.dart'; import 'package:flutter_go/views/collection_page/collection_page.dart'; import 'package:flutter_go/views/collection_page/collection_full_page.dart'; import 'package:flutter_go/views/standard_demo_page/index.dart'; +import 'package:flutter_go/views/issuse_message_page/issuse_message_page.dart' // app的首页 var homeHandler = new Handler( @@ -72,3 +73,9 @@ var standardPageHandler = new Handler( return StandardView(id: id); } ); + + +var issuesMessageHandler = new Handler( + handlerFunc: (BuildContext context, Map> params) { + return IssuesMessagePage(); + }); diff --git a/lib/routers/routers.dart b/lib/routers/routers.dart index a1827819..dc1183a5 100644 --- a/lib/routers/routers.dart +++ b/lib/routers/routers.dart @@ -30,7 +30,7 @@ class Routes { router.define(loginPage, handler: loginPageHandler); router.define(codeView,handler:fullScreenCodeDialog); router.define(webViewPage,handler:webViewPageHand); - // router.define(issuesMessage, handler: issuesMessageHandler); + router.define(issuesMessage, handler: issuesMessageHandler); widgetDemosList.forEach((demo) { Handler handler = new Handler( handlerFunc: (BuildContext context, Map> params) { diff --git a/lib/utils/data_utils.dart b/lib/utils/data_utils.dart index 8821ead9..dc0d9036 100644 --- a/lib/utils/data_utils.dart +++ b/lib/utils/data_utils.dart @@ -57,12 +57,11 @@ class DataUtils { // 一键反馈 static Future feedback(Map params, context) async { var response = await NetUtils.post(Api.FEEDBACK, params); - // print(response); if (response['status'] == 401 && response['message'] == '请先登录') { Application.router.navigateTo(context, '${Routes.loginPage}', transition: TransitionType.nativeModal); } - return response; + return response['success']; } //设置主题颜色 diff --git a/lib/views/first_page/drawer_page.dart b/lib/views/first_page/drawer_page.dart index 1ec7df70..52a9da0c 100644 --- a/lib/views/first_page/drawer_page.dart +++ b/lib/views/first_page/drawer_page.dart @@ -205,28 +205,14 @@ class _DrawerPageState extends State { onTap: () { if (hasLogin) { //issue 未登陆状态 返回登陆页面 - DataUtils.logout().then((result) { - Application.router - .navigateTo(context, '${Routes.issuesMessage}'); - }); + Application.router.navigateTo(context, '${Routes.issuesMessage}'); } else { //No description provided. Application.router.navigateTo(context, '${Routes.loginPage}'); - // Application.router.navigateTo(context, '${Routes.issuesMessage}'); + } }, ), - // ListTile( - // leading: Icon( - // Icons.info, - // size: 27.0, - // ), - // title: Text( - // '关于 App', - // style: textStyle, - // ), - // onTap: () {}, - // ), ListTile( leading: Icon( Icons.share, diff --git a/lib/views/issuse_message_page/issuse_message_page.dart b/lib/views/issuse_message_page/issuse_message_page.dart new file mode 100644 index 00000000..60a0cdde --- /dev/null +++ b/lib/views/issuse_message_page/issuse_message_page.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:zefyr/zefyr.dart'; +import 'package:flutter_go/utils/data_utils.dart'; +import 'package:notus/convert.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + + + +class IssuesMessagePage extends StatefulWidget { + @override + _IssuesMessagePageState createState() => _IssuesMessagePageState(); +} + +class _IssuesMessagePageState extends State { + final TextEditingController _controller = new TextEditingController(); + final ZefyrController _zefyrController = new ZefyrController(NotusDocument()); + final FocusNode _focusNode = new FocusNode(); + String _title = ""; + var _delta; + + @override + void initState() { + _controller.addListener(() { + print("_controller.text:${_controller.text}"); + setState(() { + _title = _controller.text; + }); + }); + + _zefyrController.document.changes.listen((change) { + setState(() { + _delta = _zefyrController.document.toDelta(); + }); + }); + + super.initState(); + } + + void dispose() { + _controller.dispose(); + _zefyrController.dispose(); + super.dispose(); + } + + _submit() { + String mk = notusMarkdown.encode(_delta); + if (_title.trim().isEmpty) { + _show('标题不能为空'); + } else { + DataUtils.feedback({'title': _title, "body": mk},context).then((result) { + _show('提交成功'); + Navigator.maybePop(context); + }); + } + } + + _show(String msgs){ + Fluttertoast.showToast( + msg: msgs, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.CENTER, + timeInSecForIos: 1, + backgroundColor: Theme.of(context).primaryColor, + textColor: Colors.white, + fontSize: 16.0); + } + + Widget buildLoading() { + return Opacity( + opacity: .5, + child: Container( + width: MediaQuery.of(context).size.width * 0.85, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + color: Colors.black, + ), + child: SpinKitPouringHourglass(color: Colors.white), + ), + ); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('反馈/意见'), + actions: [ + FlatButton.icon( + onPressed: () { + _submit(); + }, + icon: Icon( + Icons.near_me, + color: Colors.white, + size: 12, + ), + label: Text( + '发送', + style: TextStyle(color: Colors.white), + ), + ) + ], + elevation: 1.0, + ), + body: ZefyrScaffold( + child: Padding( + padding: EdgeInsets.all(8), + child: ListView( + children: [ + Text('输入标题:'), + new TextFormField( + maxLength: 50, + controller: _controller, + decoration: new InputDecoration( + hintText: 'Title', + ), + ), + Text('内容:'), + _descriptionEditor(), + ], + ), + ), + )); + } + + Widget _descriptionEditor() { + 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: 400.0, + decoration: InputDecoration(labelText: 'Description'), + controller: _zefyrController, + focusNode: _focusNode, + autofocus: true, + physics: ClampingScrollPhysics(), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6b7ba9f1..1b6d1d35 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,105 +5,105 @@ packages: dependency: transitive description: name: args - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.5.2" async: dependency: transitive description: name: async - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.2.0" bloc: dependency: "direct main" description: name: bloc - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.0.4" charcode: dependency: transitive description: name: charcode - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.2" city_pickers: dependency: "direct main" description: name: city_pickers - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.0.4" collection: dependency: transitive description: name: collection - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.14.11" cookie_jar: dependency: "direct main" description: name: cookie_jar - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.0.1" csslib: dependency: transitive description: name: csslib - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.16.1" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.1.2" dio: dependency: "direct main" description: name: dio - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.1.13" event_bus: dependency: "direct main" description: name: event_bus - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.0" firebase_analytics: dependency: "direct main" description: name: firebase_analytics - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.2.0+1" firebase_core: dependency: "direct main" description: name: firebase_core - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.3.4" fluro: dependency: "direct main" description: name: fluro - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.5.1" flutter: @@ -115,28 +115,28 @@ packages: dependency: "direct main" description: name: flutter_bloc - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.11.1" flutter_downloader: dependency: "direct main" description: name: flutter_downloader - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.9" flutter_jpush: dependency: "direct main" description: name: flutter_jpush - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.0.4" flutter_spinkit: dependency: "direct main" description: name: flutter_spinkit - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "3.1.0" flutter_test: @@ -148,133 +148,154 @@ packages: dependency: "direct main" description: name: flutter_webview_plugin - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.3.5" fluttertoast: dependency: "direct main" description: name: fluttertoast - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "3.1.0" html: dependency: "direct main" description: name: html - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.14.0+2" image_picker: dependency: "direct main" description: name: image_picker - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.5.4+3" intl: dependency: "direct main" description: name: intl - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.15.7" lpinyin: dependency: transitive description: name: lpinyin - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.0.7" markdown: dependency: "direct main" description: name: markdown - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.0.3" matcher: dependency: transitive description: name: matcher - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.12.5" meta: dependency: "direct main" description: name: meta - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.6" + notus: + dependency: transitive + description: + name: notus + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" open_file: dependency: "direct main" description: name: open_file - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.0.3" package_info: dependency: "direct main" description: name: package_info - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.4.0+6" path: dependency: "direct main" description: name: path - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.6.2" path_provider: dependency: "direct main" description: name: path_provider - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.2.0" pedantic: dependency: transitive description: name: pedantic - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.7.0" permission_handler: dependency: "direct main" description: name: permission_handler - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted - version: "3.2.1+1" + version: "3.2.0" + quill_delta: + dependency: transitive + description: + name: quill_delta + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" quiver: dependency: transitive description: name: quiver - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.0.3" + quiver_hashcode: + dependency: transitive + description: + name: quiver_hashcode + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" rxdart: dependency: transitive description: name: rxdart - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.21.0" share: dependency: "direct main" description: name: share - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.6.2+1" shared_preferences: dependency: "direct main" description: name: shared_preferences - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.4.3" sky_engine: @@ -286,79 +307,86 @@ packages: dependency: transitive description: name: source_span - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.5.5" sqflite: dependency: "direct main" description: name: sqflite - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.6+3" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.9.3" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.0.0" string_scanner: dependency: "direct main" description: name: string_scanner - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.0.4" synchronized: dependency: transitive description: name: synchronized - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.1.0+1" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.0" test_api: dependency: transitive description: name: test_api - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "0.2.5" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "1.1.6" url_launcher: dependency: "direct main" description: name: url_launcher - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "5.1.1" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.flutter-io.cn" + url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + zefyr: + dependency: "direct main" + description: + path: zefyr + relative: true + source: path + version: "0.0.1" sdks: dart: ">=2.2.2 <3.0.0" flutter: ">=1.6.0 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index b2cd154f..7dba5e7f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,9 @@ dependencies: open_file: ^2.0.1+2 package_info: ^0.4.0+3 flutter_jpush: ^0.0.4 + zefyr: + path: ./zefyr + dev_dependencies: flutter_test: diff --git a/zefyr/.gitignore b/zefyr/.gitignore new file mode 100644 index 00000000..9f87252c --- /dev/null +++ b/zefyr/.gitignore @@ -0,0 +1,72 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/zefyr/CHANGELOG.md b/zefyr/CHANGELOG.md new file mode 100644 index 00000000..ac071598 --- /dev/null +++ b/zefyr/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - TODO: Add release date. + +* TODO: Describe initial release. diff --git a/zefyr/LICENSE b/zefyr/LICENSE new file mode 100644 index 00000000..ba75c69f --- /dev/null +++ b/zefyr/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/zefyr/README.md b/zefyr/README.md new file mode 100644 index 00000000..cc0c4567 --- /dev/null +++ b/zefyr/README.md @@ -0,0 +1,14 @@ +# zefyr + +A new Flutter package project. + +## Getting Started + +This project is a starting point for a Dart +[package](https://flutter.dev/developing-packages/), +a library module containing code that can be shared easily across +multiple Flutter or Dart projects. + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/zefyr/lib/src/fast_diff.dart b/zefyr/lib/src/fast_diff.dart new file mode 100644 index 00000000..ade850b6 --- /dev/null +++ b/zefyr/lib/src/fast_diff.dart @@ -0,0 +1,40 @@ +// 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:math' as math; + +/// Performs a fast diff operation on two input strings based on provided +/// [cursorPosition]. +DiffResult fastDiff(String oldText, String newText, int cursorPosition) { + var delta = newText.length - oldText.length; + var limit = math.max(0, cursorPosition - delta); + var end = oldText.length; + while (end > limit && oldText[end - 1] == newText[end + delta - 1]) { + end -= 1; + } + var start = 0; + var startLimit = cursorPosition - math.max(0, delta); + while (start < startLimit && oldText[start] == newText[start]) { + start += 1; + } + final String deleted = (start < end) ? oldText.substring(start, end) : ''; + final inserted = newText.substring(start, end + delta); + return new DiffResult(start, deleted, inserted); +} + +/// A diff between two strings of text. +class DiffResult { + /// Start index in old text at which changes begin. + final int start; + + /// Deleted text in old text. + final String deleted; + + /// Inserted text. + final String inserted; + + DiffResult(this.start, this.deleted, this.inserted); + + @override + String toString() => 'DiffResult[$start, "$deleted", "$inserted"]'; +} diff --git a/zefyr/lib/src/widgets/buttons.dart b/zefyr/lib/src/widgets/buttons.dart new file mode 100644 index 00000000..675f4ea3 --- /dev/null +++ b/zefyr/lib/src/widgets/buttons.dart @@ -0,0 +1,583 @@ +// 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 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:notus/notus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'scope.dart'; +import 'theme.dart'; +import 'toolbar.dart'; + +/// A button used in [ZefyrToolbar]. +/// +/// Create an instance of this widget with [ZefyrButton.icon] or +/// [ZefyrButton.text] constructors. +/// +/// Toolbar buttons are normally created by a [ZefyrToolbarDelegate]. +class ZefyrButton extends StatelessWidget { + /// Creates a toolbar button with an icon. + ZefyrButton.icon({ + @required this.action, + @required IconData icon, + double iconSize, + this.onPressed, + }) : assert(action != null), + assert(icon != null), + _icon = icon, + _iconSize = iconSize, + _text = null, + _textStyle = null, + super(); + + /// Creates a toolbar button containing text. + /// + /// Note that [ZefyrButton] has fixed width and does not expand to accommodate + /// long texts. + ZefyrButton.text({ + @required this.action, + @required String text, + TextStyle style, + this.onPressed, + }) : assert(action != null), + assert(text != null), + _icon = null, + _iconSize = null, + _text = text, + _textStyle = style, + super(); + + /// Toolbar action associated with this button. + final ZefyrToolbarAction action; + final IconData _icon; + final double _iconSize; + final String _text; + final TextStyle _textStyle; + + /// Callback to trigger when this button is tapped. + final VoidCallback onPressed; + + bool get isAttributeAction { + return kZefyrToolbarAttributeActions.keys.contains(action); + } + + @override + Widget build(BuildContext 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) + ? toolbarTheme.disabledIconColor + : toolbarTheme.iconColor; + if (_icon != null) { + return RawZefyrButton.icon( + action: action, + icon: _icon, + size: _iconSize, + iconColor: iconColor, + color: _getColor(editor, toolbarTheme), + onPressed: _getPressedHandler(editor, toolbar), + ); + } else { + assert(_text != null); + var style = _textStyle ?? new TextStyle(); + style = style.copyWith(color: iconColor); + return RawZefyrButton( + action: action, + child: new Text(_text, style: style), + color: _getColor(editor, toolbarTheme), + onPressed: _getPressedHandler(editor, toolbar), + ); + } + } + + Color _getColor(ZefyrScope editor, ZefyrToolbarTheme theme) { + if (isAttributeAction) { + final attribute = kZefyrToolbarAttributeActions[action]; + final isToggled = (attribute is NotusAttribute) + ? editor.selectionStyle.containsSame(attribute) + : editor.selectionStyle.contains(attribute); + return isToggled ? theme.toggleColor : null; + } + return null; + } + + VoidCallback _getPressedHandler( + ZefyrScope editor, ZefyrToolbarState toolbar) { + if (onPressed != null) { + return onPressed; + } else if (isAttributeAction) { + final attribute = kZefyrToolbarAttributeActions[action]; + if (attribute is NotusAttribute) { + return () => _toggleAttribute(attribute, editor); + } + } else if (action == ZefyrToolbarAction.close) { + return () => toolbar.closeOverlay(); + } else if (action == ZefyrToolbarAction.hideKeyboard) { + return () => editor.hideKeyboard(); + } + + return null; + } + + void _toggleAttribute(NotusAttribute attribute, ZefyrScope editor) { + final isToggled = editor.selectionStyle.containsSame(attribute); + if (isToggled) { + editor.formatSelection(attribute.unset); + } else { + editor.formatSelection(attribute); + } + } +} + +/// Raw button widget used by [ZefyrToolbar]. +/// +/// See also: +/// +/// * [ZefyrButton], which wraps this widget and implements most of the +/// action-specific logic. +class RawZefyrButton extends StatelessWidget { + const RawZefyrButton({ + Key key, + @required this.action, + @required this.child, + @required this.color, + @required this.onPressed, + }) : super(key: key); + + /// Creates a [RawZefyrButton] containing an icon. + RawZefyrButton.icon({ + @required this.action, + @required IconData icon, + double size, + Color iconColor, + @required this.color, + @required this.onPressed, + }) : child = new Icon(icon, size: size, color: iconColor), + super(); + + /// Toolbar action associated with this button. + final ZefyrToolbarAction action; + + /// Child widget to show inside this button. Usually an icon. + final Widget child; + + /// Background color of this button. + final Color color; + + /// Callback to trigger when this button is pressed. + final VoidCallback onPressed; + + /// Returns `true` if this button is currently toggled on. + bool get isToggled => color != null; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final width = theme.buttonTheme.constraints.minHeight + 4.0; + final constraints = theme.buttonTheme.constraints.copyWith( + minWidth: width, maxHeight: theme.buttonTheme.constraints.minHeight); + final radius = BorderRadius.all(Radius.circular(3.0)); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 6.0), + child: RawMaterialButton( + shape: RoundedRectangleBorder(borderRadius: radius), + elevation: 0.0, + fillColor: color, + constraints: constraints, + onPressed: onPressed, + child: child, + ), + ); + } +} + +/// Controls heading styles. +/// +/// When pressed, this button displays overlay toolbar with three +/// buttons for each heading level. +class HeadingButton extends StatefulWidget { + const HeadingButton({Key key}) : super(key: key); + + @override + _HeadingButtonState createState() => _HeadingButtonState(); +} + +class _HeadingButtonState extends State { + @override + Widget build(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + return toolbar.buildButton( + context, + ZefyrToolbarAction.heading, + onPressed: showOverlay, + ); + } + + void showOverlay() { + final toolbar = ZefyrToolbar.of(context); + toolbar.showOverlay(buildOverlay); + } + + Widget buildOverlay(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + final buttons = Row( + children: [ + SizedBox(width: 8.0), + toolbar.buildButton(context, ZefyrToolbarAction.headingLevel1), + toolbar.buildButton(context, ZefyrToolbarAction.headingLevel2), + toolbar.buildButton(context, ZefyrToolbarAction.headingLevel3), + ], + ); + return ZefyrToolbarScaffold(body: buttons); + } +} + +/// Controls image attribute. +/// +/// When pressed, this button displays overlay toolbar with three +/// buttons for each heading level. +class ImageButton extends StatefulWidget { + const ImageButton({Key key}) : super(key: key); + + @override + _ImageButtonState createState() => _ImageButtonState(); +} + +class _ImageButtonState extends State { + @override + Widget build(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + return toolbar.buildButton( + context, + ZefyrToolbarAction.image, + onPressed: showOverlay, + ); + } + + void showOverlay() { + final toolbar = ZefyrToolbar.of(context); + toolbar.showOverlay(buildOverlay); + } + + Widget buildOverlay(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + final buttons = Row( + children: [ + SizedBox(width: 8.0), + toolbar.buildButton(context, ZefyrToolbarAction.cameraImage, + onPressed: _pickFromCamera), + toolbar.buildButton(context, ZefyrToolbarAction.galleryImage, + onPressed: _pickFromGallery), + ], + ); + return ZefyrToolbarScaffold(body: buttons); + } + + void _pickFromCamera() async { + 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 = ZefyrToolbar.of(context).editor; + final image = await editor.imageDelegate.pickImage(ImageSource.gallery); + if (image != null) + editor.formatSelection(NotusAttribute.embed.image(image)); + } +} + +class LinkButton extends StatefulWidget { + const LinkButton({Key key}) : super(key: key); + + @override + _LinkButtonState createState() => _LinkButtonState(); +} + +class _LinkButtonState extends State { + final TextEditingController _inputController = TextEditingController(); + Key _inputKey; + bool _formatError = false; + ZefyrScope _editor; + + bool get isEditing => _inputKey != null; + + @override + Widget build(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + final editor = toolbar.editor; + final enabled = + hasLink(editor.selectionStyle) || !editor.selection.isCollapsed; + + return toolbar.buildButton( + context, + ZefyrToolbarAction.link, + onPressed: enabled ? showOverlay : null, + ); + } + + bool hasLink(NotusStyle style) => style.contains(NotusAttribute.link); + + String getLink([String defaultValue]) { + final editor = ZefyrToolbar.of(context).editor; + final attrs = editor.selectionStyle; + if (hasLink(attrs)) { + return attrs.value(NotusAttribute.link); + } + return defaultValue; + } + + void showOverlay() { + final toolbar = ZefyrToolbar.of(context); + toolbar.showOverlay(buildOverlay).whenComplete(cancelEdit); + } + + void closeOverlay() { + final toolbar = ZefyrToolbar.of(context); + toolbar.closeOverlay(); + } + + void edit() { + final toolbar = ZefyrToolbar.of(context); + setState(() { + _inputKey = new UniqueKey(); + _inputController.text = getLink('https://'); + _inputController.addListener(_handleInputChange); + toolbar.markNeedsRebuild(); + }); + } + + void doneEdit() { + final toolbar = ZefyrToolbar.of(context); + setState(() { + var error = false; + if (_inputController.text.isNotEmpty) { + try { + var uri = Uri.parse(_inputController.text); + if ((uri.isScheme('https') || uri.isScheme('http')) && + uri.host.isNotEmpty) { + toolbar.editor.formatSelection( + NotusAttribute.link.fromString(_inputController.text)); + } else { + error = true; + } + } on FormatException { + error = true; + } + } + if (error) { + _formatError = error; + toolbar.markNeedsRebuild(); + } else { + _inputKey = null; + _inputController.text = ''; + _inputController.removeListener(_handleInputChange); + toolbar.markNeedsRebuild(); + toolbar.editor.focus(); + } + }); + } + + void cancelEdit() { + if (mounted) { + final editor = ZefyrToolbar.of(context).editor; + setState(() { + _inputKey = null; + _inputController.text = ''; + _inputController.removeListener(_handleInputChange); + editor.focus(); + }); + } + } + + void unlink() { + final editor = ZefyrToolbar.of(context).editor; + editor.formatSelection(NotusAttribute.link.unset); + closeOverlay(); + } + + void copyToClipboard() { + var link = getLink(); + assert(link != null); + Clipboard.setData(new ClipboardData(text: link)); + } + + void openInBrowser() async { + final editor = ZefyrToolbar.of(context).editor; + var link = getLink(); + assert(link != null); + if (await canLaunch(link)) { + editor.hideKeyboard(); + await launch(link, forceWebView: true); + } + } + + void _handleInputChange() { + final toolbar = ZefyrToolbar.of(context); + setState(() { + _formatError = false; + toolbar.markNeedsRebuild(); + }); + } + + Widget buildOverlay(BuildContext context) { + final toolbar = ZefyrToolbar.of(context); + final style = toolbar.editor.selectionStyle; + + String value = 'Tap to edit link'; + if (style.contains(NotusAttribute.link)) { + value = style.value(NotusAttribute.link); + } + final clipboardEnabled = value != 'Tap to edit link'; + final body = !isEditing + ? _LinkView(value: value, onTap: edit) + : _LinkInput( + key: _inputKey, + controller: _inputController, + formatError: _formatError, + ); + final items = [Expanded(child: body)]; + if (!isEditing) { + final unlinkHandler = hasLink(style) ? unlink : null; + final copyHandler = clipboardEnabled ? copyToClipboard : null; + final openHandler = hasLink(style) ? openInBrowser : null; + final buttons = [ + toolbar.buildButton(context, ZefyrToolbarAction.unlink, + onPressed: unlinkHandler), + toolbar.buildButton(context, ZefyrToolbarAction.clipboardCopy, + onPressed: copyHandler), + toolbar.buildButton( + context, + ZefyrToolbarAction.openInBrowser, + onPressed: openHandler, + ), + ]; + items.addAll(buttons); + } + final trailingPressed = isEditing ? doneEdit : closeOverlay; + final trailingAction = + isEditing ? ZefyrToolbarAction.confirm : ZefyrToolbarAction.close; + + return ZefyrToolbarScaffold( + body: Row(children: items), + trailing: toolbar.buildButton( + context, + trailingAction, + onPressed: trailingPressed, + ), + ); + } +} + +class _LinkInput extends StatefulWidget { + final TextEditingController controller; + final bool formatError; + + const _LinkInput( + {Key key, @required this.controller, this.formatError: false}) + : super(key: key); + + @override + _LinkInputState createState() { + return new _LinkInputState(); + } +} + +class _LinkInputState extends State<_LinkInput> { + final FocusNode _focusNode = FocusNode(); + + ZefyrScope _editor; + bool _didAutoFocus = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didAutoFocus) { + FocusScope.of(context).requestFocus(_focusNode); + _didAutoFocus = true; + } + + final toolbar = ZefyrToolbar.of(context); + + if (_editor != toolbar.editor) { + _editor?.toolbarFocusNode = null; + _editor = toolbar.editor; + _editor.toolbarFocusNode = _focusNode; + } + } + + @override + void dispose() { + _editor?.toolbarFocusNode = 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 = + widget.formatError ? Colors.redAccent : toolbarTheme.iconColor; + final style = theme.textTheme.subhead.copyWith(color: color); + return TextField( + style: style, + keyboardType: TextInputType.url, + 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), + ), + ); + } +} + +class _LinkView extends StatelessWidget { + const _LinkView({Key key, @required this.value, this.onTap}) + : super(key: key); + final String value; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final toolbarTheme = ZefyrTheme.of(context).toolbarTheme; + Widget widget = new ClipRect( + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + Container( + alignment: AlignmentDirectional.centerStart, + constraints: BoxConstraints(minHeight: ZefyrToolbar.kToolbarHeight), + padding: const EdgeInsets.all(10.0), + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.subhead + .copyWith(color: toolbarTheme.disabledIconColor), + ), + ) + ], + ), + ); + if (onTap != null) { + widget = GestureDetector( + child: widget, + onTap: onTap, + ); + } + return widget; + } +} diff --git a/zefyr/lib/src/widgets/caret.dart b/zefyr/lib/src/widgets/caret.dart new file mode 100644 index 00000000..895cd3b4 --- /dev/null +++ b/zefyr/lib/src/widgets/caret.dart @@ -0,0 +1,42 @@ +// 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:ui'; + +import 'package:flutter/material.dart'; + +/// Helper class responsible for cursor layout and painting. +class CursorPainter { + static const double _kCaretHeightOffset = 2.0; // pixels + static const double _kCaretWidth = 1.0; // pixels + + static Rect buildPrototype(double lineHeight) { + return new Rect.fromLTWH( + 0.0, 0.0, _kCaretWidth, lineHeight - _kCaretHeightOffset); + } + + CursorPainter(Color color) + : assert(color != null), + _color = color; + + Rect _prototype; + + Rect get prototype => _prototype; + + Color _color; + Color get color => _color; + set color(Color value) { + assert(value != null); + _color = value; + } + + void layout(double lineHeight) { + _prototype = buildPrototype(lineHeight); + } + + void paint(Canvas canvas, Offset offset) { + final Paint paint = new Paint()..color = _color; + final Rect caretRect = _prototype.shift(offset); + canvas.drawRect(caretRect, paint); + } +} diff --git a/zefyr/lib/src/widgets/code.dart b/zefyr/lib/src/widgets/code.dart new file mode 100644 index 00000000..1c5c6050 --- /dev/null +++ b/zefyr/lib/src/widgets/code.dart @@ -0,0 +1,47 @@ +// 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 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'common.dart'; +import 'theme.dart'; + +/// Represents a code snippet in Zefyr editor. +class ZefyrCode extends StatelessWidget { + const ZefyrCode({Key key, @required this.node}) : super(key: key); + + /// Document node represented by this widget. + final BlockNode node; + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context); + + List items = []; + for (var line in node.children) { + items.add(_buildLine(line, theme.blockTheme.code.textStyle)); + } + + return new Padding( + padding: theme.blockTheme.code.padding, + child: new Container( + // TODO: make decorations configurable + decoration: BoxDecoration( + color: Colors.blueGrey.shade50, + borderRadius: BorderRadius.circular(3.0), + ), + padding: const EdgeInsets.all(16.0), + child: new Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: items, + ), + ), + ); + } + + Widget _buildLine(Node node, TextStyle style) { + LineNode line = node; + return new RawZefyrLine(node: line, style: style); + } +} diff --git a/zefyr/lib/src/widgets/common.dart b/zefyr/lib/src/widgets/common.dart new file mode 100644 index 00000000..aabc8b8f --- /dev/null +++ b/zefyr/lib/src/widgets/common.dart @@ -0,0 +1,155 @@ +// 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 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; + +import 'editable_box.dart'; +import 'horizontal_rule.dart'; +import 'image.dart'; +import 'rich_text.dart'; +import 'scope.dart'; +import 'theme.dart'; + +/// Raw widget representing a single line of rich text document in Zefyr editor. +/// +/// See [ZefyrParagraph] and [ZefyrHeading] which wrap this widget and +/// integrate it with current [ZefyrTheme]. +class RawZefyrLine extends StatefulWidget { + const RawZefyrLine({ + Key key, + @required this.node, + this.style, + this.padding, + }) : super(key: key); + + /// Line in the document represented by this widget. + final LineNode node; + + /// Style to apply to this line. Required for lines with text contents, + /// ignored for lines containing embeds. + final TextStyle style; + + /// Padding to add around this paragraph. + final EdgeInsets padding; + + @override + _RawZefyrLineState createState() => new _RawZefyrLineState(); +} + +class _RawZefyrLineState extends State { + final LayerLink _link = new LayerLink(); + + @override + Widget build(BuildContext context) { + final scope = ZefyrScope.of(context); + if (scope.isEditable) { + ensureVisible(context, scope); + } + final theme = ZefyrTheme.of(context); + + Widget content; + if (widget.node.hasEmbed) { + content = buildEmbed(context, scope); + } else { + assert(widget.style != null); + content = ZefyrRichText( + node: widget.node, + text: buildText(context), + ); + } + + if (scope.isEditable) { + content = EditableBox( + child: content, + node: widget.node, + layerLink: _link, + renderContext: scope.renderContext, + showCursor: scope.showCursor, + selection: scope.selection, + selectionColor: theme.selectionColor, + cursorColor: theme.cursorColor, + ); + content = CompositedTransformTarget(link: _link, child: content); + } + + if (widget.padding != null) { + return Padding(padding: widget.padding, child: content); + } + return content; + } + + void ensureVisible(BuildContext context, ZefyrScope scope) { + if (scope.selection.isCollapsed && + widget.node.containsOffset(scope.selection.extentOffset)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + bringIntoView(context); + }); + } + } + + void bringIntoView(BuildContext context) { + ScrollableState scrollable = Scrollable.of(context); + final object = context.findRenderObject(); + assert(object.attached); + final RenderAbstractViewport viewport = RenderAbstractViewport.of(object); + assert(viewport != null); + + final double offset = scrollable.position.pixels; + double target = viewport.getOffsetToReveal(object, 0.0).offset; + if (target - offset < 0.0) { + scrollable.position.jumpTo(target); + return; + } + target = viewport.getOffsetToReveal(object, 1.0).offset; + if (target - offset > 0.0) { + scrollable.position.jumpTo(target); + } + } + + TextSpan buildText(BuildContext context) { + final theme = ZefyrTheme.of(context); + final List children = widget.node.children + .map((node) => _segmentToTextSpan(node, theme)) + .toList(growable: false); + return new TextSpan(style: widget.style, children: children); + } + + TextSpan _segmentToTextSpan(Node node, ZefyrThemeData theme) { + final TextNode segment = node; + final attrs = segment.style; + + return new TextSpan( + text: segment.value, + style: _getTextStyle(attrs, theme), + ); + } + + TextStyle _getTextStyle(NotusStyle style, ZefyrThemeData theme) { + TextStyle result = new TextStyle(); + if (style.containsSame(NotusAttribute.bold)) { + result = result.merge(theme.boldStyle); + } + if (style.containsSame(NotusAttribute.italic)) { + result = result.merge(theme.italicStyle); + } + if (style.contains(NotusAttribute.link)) { + result = result.merge(theme.linkStyle); + } + return result; + } + + Widget buildEmbed(BuildContext context, ZefyrScope scope) { + EmbedNode node = widget.node.children.single; + EmbedAttribute embed = node.style.get(NotusAttribute.embed); + + if (embed.type == EmbedType.horizontalRule) { + return ZefyrHorizontalRule(node: node); + } else if (embed.type == EmbedType.image) { + return ZefyrImage(node: node, delegate: scope.imageDelegate); + } else { + throw new UnimplementedError('Unimplemented embed type ${embed.type}'); + } + } +} diff --git a/zefyr/lib/src/widgets/controller.dart b/zefyr/lib/src/widgets/controller.dart new file mode 100644 index 00000000..325769d3 --- /dev/null +++ b/zefyr/lib/src/widgets/controller.dart @@ -0,0 +1,177 @@ +// 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:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; +import 'package:quill_delta/quill_delta.dart'; +import 'package:zefyr/util.dart'; + +const TextSelection _kZeroSelection = const TextSelection.collapsed( + offset: 0, + affinity: TextAffinity.upstream, +); + +/// Owner of focus. +enum FocusOwner { + /// Current owner is the editor. + editor, + + /// Current owner is the toolbar. + toolbar, + + /// No focus owner. + none, +} + +/// Controls instance of [ZefyrEditor]. +class ZefyrController extends ChangeNotifier { + ZefyrController(NotusDocument document) + : assert(document != null), + _document = document; + + /// Zefyr document managed by this controller. + NotusDocument get document => _document; + NotusDocument _document; + + /// Currently selected text within the [document]. + TextSelection get selection => _selection; + TextSelection _selection = _kZeroSelection; + + ChangeSource _lastChangeSource; + + /// Source of the last text or selection change. + ChangeSource get lastChangeSource => _lastChangeSource; + + /// Updates selection with specified [value]. + /// + /// [value] and [source] cannot be `null`. + void updateSelection(TextSelection value, + {ChangeSource source: ChangeSource.remote}) { + _updateSelectionSilent(value, source: source); + notifyListeners(); + } + + // Updates selection without triggering notifications to listeners. + void _updateSelectionSilent(TextSelection value, + {ChangeSource source: ChangeSource.remote}) { + assert(value != null && source != null); + _selection = value; + _lastChangeSource = source; + _ensureSelectionBeforeLastBreak(); + } + + @override + void dispose() { + _document.close(); + super.dispose(); + } + + /// Composes [change] into document managed by this controller. + /// + /// This method does not apply any adjustments or heuristic rules to + /// provided [change] and it is caller's responsibility to ensure this change + /// can be composed without errors. + /// + /// If composing this change fails then this method throws [ComposeError]. + void compose(Delta change, + {TextSelection selection, ChangeSource source: ChangeSource.remote}) { + if (change.isNotEmpty) { + _document.compose(change, source); + } + if (selection != null) { + _updateSelectionSilent(selection, source: source); + } else { + // Transform selection against the composed change and give priority to + // current position (force: false). + final base = + change.transformPosition(_selection.baseOffset, force: false); + final extent = + change.transformPosition(_selection.extentOffset, force: false); + selection = _selection.copyWith(baseOffset: base, extentOffset: extent); + if (_selection != selection) { + _updateSelectionSilent(selection, source: source); + } + } + _lastChangeSource = source; + notifyListeners(); + } + + void replaceText(int index, int length, String text, + {TextSelection selection}) { + Delta delta; + + if (length > 0 || text.isNotEmpty) { + delta = document.replace(index, length, text); + } + + if (selection != null) { + if (delta == null) { + _updateSelectionSilent(selection, source: ChangeSource.local); + } else { + // need to transform selection position in case actual delta + // is different from user's version (in deletes and inserts). + Delta user = new Delta() + ..retain(index) + ..insert(text) + ..delete(length); + int positionDelta = getPositionDelta(user, delta); + _updateSelectionSilent( + selection.copyWith( + baseOffset: selection.baseOffset + positionDelta, + extentOffset: selection.extentOffset + positionDelta, + ), + source: ChangeSource.local, + ); + } + } + _lastChangeSource = ChangeSource.local; + notifyListeners(); + } + + void formatText(int index, int length, NotusAttribute 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(); + } + + /// Formats current selection with [attribute]. + void formatSelection(NotusAttribute attribute) { + int index = _selection.start; + int length = _selection.end - index; + formatText(index, length, attribute); + } + + NotusStyle getSelectionStyle() { + int start = _selection.start; + int length = _selection.end - start; + return _document.collectStyle(start, length); + } + + TextEditingValue get plainTextEditingValue { + return new TextEditingValue( + text: document.toPlainText(), + selection: selection, + composing: new TextRange.collapsed(0), + ); + } + + void _ensureSelectionBeforeLastBreak() { + final end = _document.length - 1; + final base = math.min(_selection.baseOffset, end); + final extent = math.min(_selection.extentOffset, end); + _selection = _selection.copyWith(baseOffset: base, extentOffset: extent); + } +} diff --git a/zefyr/lib/src/widgets/cursor_timer.dart b/zefyr/lib/src/widgets/cursor_timer.dart new file mode 100644 index 00000000..947dc5ab --- /dev/null +++ b/zefyr/lib/src/widgets/cursor_timer.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Helper class that keeps state relevant to the editing cursor. +class CursorTimer { + static const _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); + + Timer _timer; + final ValueNotifier _showCursor = new ValueNotifier(false); + + ValueNotifier get value => _showCursor; + + void _cursorTick(Timer timer) { + _showCursor.value = !_showCursor.value; + } + + /// Starts cursor timer. + void start() { + _showCursor.value = true; + _timer = new Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); + } + + /// Stops cursor timer. + void stop() { + _timer?.cancel(); + _timer = null; + _showCursor.value = false; + } + + /// Starts or stops cursor timer based on current state of [focusNode] + /// and [selection]. + void startOrStop(FocusNode focusNode, TextSelection selection) { + final hasFocus = focusNode.hasFocus; + final selectionCollapsed = selection.isCollapsed; + if (_timer == null && hasFocus && selectionCollapsed) { + start(); + } else if (_timer != null && (!hasFocus || !selectionCollapsed)) { + stop(); + } + } +} diff --git a/zefyr/lib/src/widgets/editable_box.dart b/zefyr/lib/src/widgets/editable_box.dart new file mode 100644 index 00000000..71c52f48 --- /dev/null +++ b/zefyr/lib/src/widgets/editable_box.dart @@ -0,0 +1,341 @@ +// 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:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; + +import 'caret.dart'; +import 'render_context.dart'; + +class EditableBox extends SingleChildRenderObjectWidget { + EditableBox({ + @required Widget child, + @required this.node, + @required this.layerLink, + @required this.renderContext, + @required this.showCursor, + @required this.selection, + @required this.selectionColor, + @required this.cursorColor, + }) : super(child: child); + + final ContainerNode node; + final LayerLink layerLink; + final ZefyrRenderContext renderContext; + final ValueNotifier showCursor; + final TextSelection selection; + final Color selectionColor; + final Color cursorColor; + + @override + RenderEditableProxyBox createRenderObject(BuildContext context) { + return new RenderEditableProxyBox( + node: node, + layerLink: layerLink, + renderContext: renderContext, + showCursor: showCursor, + selection: selection, + selectionColor: selectionColor, + cursorColor: cursorColor, + ); + } + + @override + void updateRenderObject( + BuildContext context, RenderEditableProxyBox renderObject) { + renderObject + ..node = node + ..layerLink = layerLink + ..renderContext = renderContext + ..showCursor = showCursor + ..selection = selection + ..selectionColor = selectionColor + ..cursorColor = cursorColor; + } +} + +class RenderEditableProxyBox extends RenderBox + with + RenderObjectWithChildMixin, + RenderProxyBoxMixin + implements RenderEditableBox { + RenderEditableProxyBox({ + RenderEditableBox child, + @required ContainerNode node, + @required LayerLink layerLink, + @required ZefyrRenderContext renderContext, + @required ValueNotifier showCursor, + @required TextSelection selection, + @required Color selectionColor, + @required Color cursorColor, + }) : _node = node, + _layerLink = layerLink, + _renderContext = renderContext, + _showCursor = showCursor, + _selection = selection, + _selectionColor = selectionColor, + super() { + this.child = child; + _cursorPainter = CursorPainter(cursorColor); + } + + CursorPainter _cursorPainter; + + set cursorColor(Color value) { + if (_cursorPainter.color != value) { + _cursorPainter.color = value; + markNeedsPaint(); + } + } + + bool _isDirty = true; + + ContainerNode get node => _node; + ContainerNode _node; + void set node(ContainerNode value) { + _node = value; + } + + LayerLink get layerLink => _layerLink; + LayerLink _layerLink; + void set layerLink(LayerLink value) { + if (_layerLink == value) return; + _layerLink = value; + } + + ZefyrRenderContext _renderContext; + void set renderContext(ZefyrRenderContext value) { + if (_renderContext == value) return; + if (attached) _renderContext.removeBox(this); + _renderContext = value; + if (attached) _renderContext.addBox(this); + } + + ValueNotifier _showCursor; + set showCursor(ValueNotifier value) { + assert(value != null); + if (_showCursor == value) return; + if (attached) _showCursor.removeListener(markNeedsCursorPaint); + _showCursor = value; + if (attached) _showCursor.addListener(markNeedsCursorPaint); + markNeedsPaint(); + } + + /// Current document selection. + TextSelection get selection => _selection; + TextSelection _selection; + set selection(TextSelection value) { + if (_selection == value) return; + // TODO: check if selection affects this block (also check previous value) + _selection = value; + markNeedsPaint(); + } + + /// Color of selection. + Color get selectionColor => _selectionColor; + Color _selectionColor; + set selectionColor(Color value) { + if (_selectionColor == value) return; + _selectionColor = value; + markNeedsPaint(); + } + + /// Returns `true` if current selection is collapsed, located within + /// this paragraph and is visible according to tick timer. + bool get isCaretVisible { + return _showCursor.value && containsCaret; + } + + /// Returns `true` if current selection is collapsed and located + /// within this paragraph. + bool get containsCaret { + if (!_selection.isCollapsed) return false; + + final int start = node.documentOffset; + final int end = start + node.length; + final int caretOffset = _selection.extentOffset; + return caretOffset >= start && caretOffset < end; + } + + /// Returns `true` if selection is not collapsed and intersects with this + /// paragraph. + bool get isSelectionVisible { + if (_selection.isCollapsed) return false; + return intersectsWithSelection(_selection); + } + + void markNeedsCursorPaint() { + if (containsCaret) { + markNeedsPaint(); + } + } + + // + // Overridden members of RenderBox + // + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _showCursor.addListener(markNeedsCursorPaint); + _renderContext.addBox(this); + _renderContext.markDirty(this, _isDirty); + } + + @override + void detach() { + _showCursor.removeListener(markNeedsCursorPaint); + _renderContext.removeBox(this); + super.detach(); + } + + @override + @mustCallSuper + void performLayout() { + super.performLayout(); + _cursorPainter.layout(preferredLineHeight); + // Indicate to render context that this object can be used by other + // layers (selection overlay, for instance). + _isDirty = false; + _renderContext.markDirty(this, false); + } + + @override + void markNeedsLayout() { + // Temporarily remove this object from the render context. + _isDirty = true; + _renderContext.markDirty(this, true); + super.markNeedsLayout(); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (selectionOrder == SelectionOrder.background && isSelectionVisible) { + paintSelection(context, offset, selection, selectionColor); + } + super.paint(context, offset); + if (selectionOrder == SelectionOrder.foreground && isSelectionVisible) { + paintSelection(context, offset, selection, selectionColor); + } + if (isCaretVisible) { + _paintCursor(context, offset); + } + } + + void _paintCursor(PaintingContext context, Offset offset) { + Offset caretOffset = + getOffsetForCaret(_selection.extent, _cursorPainter.prototype); + _cursorPainter.paint(context.canvas, caretOffset + offset); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTest(HitTestResult result, {Offset position}) { + if (size.contains(position)) { + result.add(new BoxHitTestEntry(this, position)); + return true; + } + return false; + } + + // + // Proxy methods + // + + @override + double get preferredLineHeight => child.preferredLineHeight; + + @override + SelectionOrder get selectionOrder => child.selectionOrder; + + @override + void paintSelection(PaintingContext context, Offset offset, + TextSelection selection, Color selectionColor) => + child.paintSelection(context, offset, selection, selectionColor); + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) => + child.getOffsetForCaret(position, caretPrototype); + + @override + TextSelection getLocalSelection(TextSelection documentSelection) => + child.getLocalSelection(documentSelection); + + bool intersectsWithSelection(TextSelection selection) => + child.intersectsWithSelection(selection); + + @override + List getEndpointsForSelection(TextSelection selection) => + child.getEndpointsForSelection(selection); + + @override + ui.TextPosition getPositionForOffset(ui.Offset offset) => + child.getPositionForOffset(offset); + + @override + TextRange getWordBoundary(ui.TextPosition position) => + child.getWordBoundary(position); +} + +enum SelectionOrder { + /// Background selection is painted before primary content of editable box. + background, + + /// Foreground selection is painted after primary content of editable box. + foreground, +} + +abstract class RenderEditableBox extends RenderBox { + Node get node; + double get preferredLineHeight; + + TextPosition getPositionForOffset(Offset offset); + List getEndpointsForSelection(TextSelection selection); + + /// Returns the text range of the word at the given offset. Characters not + /// part of a word, such as spaces, symbols, and punctuation, have word breaks + /// on both sides. In such cases, this method will return a text range that + /// contains the given text position. + /// + /// Word boundaries are defined more precisely in Unicode Standard Annex #29 + /// . + /// + /// Valid only after [layout]. + TextRange getWordBoundary(TextPosition position); + + /// Paint order of selection in this editable box. + SelectionOrder get selectionOrder; + + void paintSelection(PaintingContext context, Offset offset, + TextSelection selection, Color selectionColor); + + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype); + + /// Returns part of [documentSelection] local to this box. May return + /// `null`. + /// + /// [documentSelection] must not be collapsed. + TextSelection getLocalSelection(TextSelection documentSelection) { + if (!intersectsWithSelection(documentSelection)) return null; + + int nodeBase = node.documentOffset; + int nodeExtent = nodeBase + node.length; + int base = math.max(0, documentSelection.baseOffset - nodeBase); + int extent = + math.min(documentSelection.extentOffset, nodeExtent) - nodeBase; + return documentSelection.copyWith(baseOffset: base, extentOffset: extent); + } + + /// Returns `true` if this box intersects with document [selection]. + bool intersectsWithSelection(TextSelection selection) { + final int base = node.documentOffset; + final int extent = base + node.length; + return base <= selection.extentOffset && selection.baseOffset <= extent; + } +} diff --git a/zefyr/lib/src/widgets/editable_text.dart b/zefyr/lib/src/widgets/editable_text.dart new file mode 100644 index 00000000..37fdcf41 --- /dev/null +++ b/zefyr/lib/src/widgets/editable_text.dart @@ -0,0 +1,283 @@ +// 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 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; + +import 'code.dart'; +import 'common.dart'; +import 'controller.dart'; +import 'cursor_timer.dart'; +import 'editor.dart'; +import 'image.dart'; +import 'input.dart'; +import 'list.dart'; +import 'paragraph.dart'; +import 'quote.dart'; +import 'render_context.dart'; +import 'scope.dart'; +import 'selection.dart'; +import 'theme.dart'; + +/// Core widget responsible for editing Zefyr documents. +/// +/// Depends on presence of [ZefyrTheme] and [ZefyrScope] somewhere up the +/// widget tree. +/// +/// Consider using [ZefyrEditor] which wraps this widget and adds a toolbar to +/// edit style attributes. +class ZefyrEditableText extends StatefulWidget { + const ZefyrEditableText({ + Key key, + @required this.controller, + @required this.focusNode, + @required this.imageDelegate, + this.autofocus: true, + this.enabled: true, + this.padding: const EdgeInsets.symmetric(horizontal: 16.0), + this.physics, + }) : super(key: key); + + final ZefyrController controller; + final FocusNode focusNode; + final ZefyrImageDelegate imageDelegate; + final bool autofocus; + final bool enabled; + final ScrollPhysics physics; + + /// Padding around editable area. + final EdgeInsets padding; + + + + @override + _ZefyrEditableTextState createState() => new _ZefyrEditableTextState(); +} + +class _ZefyrEditableTextState extends State + with AutomaticKeepAliveClientMixin { + // + // New public members + // + + /// Focus node of this widget. + FocusNode get focusNode => widget.focusNode; + + /// Document controlled by this widget. + NotusDocument get document => widget.controller.document; + + /// Current text selection. + TextSelection get selection => widget.controller.selection; + + /// 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. + void requestKeyboard() { + if (focusNode.hasFocus) + _input.openConnection(widget.controller.plainTextEditingValue); + else + FocusScope.of(context).requestFocus(focusNode); + } + + void focusOrUnfocusIfNeeded() { + if (!_didAutoFocus && widget.autofocus && widget.enabled) { + FocusScope.of(context).autofocus(focusNode); + _didAutoFocus = true; + } + if (!widget.enabled && focusNode.hasFocus) { + _didAutoFocus = false; + focusNode.unfocus(); + } + } + + // + // Overridden members of State + // + + + + @override + Widget build(BuildContext context) { +// var reparentIfNeeded = FocusScope.of(context).reparentIfNeeded(focusNode); + + _nodeAttachment.reparent(); + super.build(context); // See AutomaticKeepAliveState. + + Widget body = ListBody(children: _buildChildren(context)); + if (widget.padding != null) { + body = new Padding(padding: widget.padding, child: body); + } + final scrollable = SingleChildScrollView( + physics: widget.physics, + controller: _scrollController, + child: body, + ); + + final overlay = Overlay.of(context, debugRequiredFor: widget); + final layers = [scrollable]; + if (widget.enabled) { + layers.add(ZefyrSelectionOverlay( + controller: widget.controller, + controls: cupertinoTextSelectionControls, + overlay: overlay, + )); + } + + return Stack(fit: StackFit.expand, children: layers); + } + + FocusAttachment _nodeAttachment; + @override + void initState() { + super.initState(); +// FocusScopeNode _node = focusNode; + _nodeAttachment = focusNode.attach(context); + _input = new InputConnectionController(_handleRemoteValueChange); + _updateSubscriptions(); + } + + @override + void didUpdateWidget(ZefyrEditableText oldWidget) { + super.didUpdateWidget(oldWidget); + _updateSubscriptions(oldWidget); + focusOrUnfocusIfNeeded(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = ZefyrScope.of(context); + if (_renderContext != scope.renderContext) { + _renderContext?.removeListener(_handleRenderContextChange); + _renderContext = scope.renderContext; + _renderContext.addListener(_handleRenderContextChange); + } + if (_cursorTimer != scope.cursorTimer) { + _cursorTimer?.stop(); + _cursorTimer = scope.cursorTimer; + _cursorTimer.startOrStop(focusNode, selection); + } + focusOrUnfocusIfNeeded(); + } + + @override + void dispose() { + _cancelSubscriptions(); + super.dispose(); + } + + // + // Overridden members of AutomaticKeepAliveClientMixin + // + + @override + bool get wantKeepAlive => focusNode.hasFocus; + + // + // Private members + // + + final ScrollController _scrollController = ScrollController(); + ZefyrRenderContext _renderContext; + CursorTimer _cursorTimer; + InputConnectionController _input; + bool _didAutoFocus = false; + + List _buildChildren(BuildContext context) { + final result = []; + for (var node in document.root.children) { + result.add(_defaultChildBuilder(context, node)); + } + return result; + } + + Widget _defaultChildBuilder(BuildContext context, Node node) { + if (node is LineNode) { + if (node.hasEmbed) { + return new RawZefyrLine(node: node); + } else if (node.style.contains(NotusAttribute.heading)) { + return new ZefyrHeading(node: node); + } + return new ZefyrParagraph(node: node); + } + + final BlockNode block = node; + final blockStyle = block.style.get(NotusAttribute.block); + if (blockStyle == NotusAttribute.block.code) { + return new ZefyrCode(node: block); + } else if (blockStyle == NotusAttribute.block.bulletList) { + return new ZefyrList(node: block); + } else if (blockStyle == NotusAttribute.block.numberList) { + return new ZefyrList(node: block); + } else if (blockStyle == NotusAttribute.block.quote) { + return new ZefyrQuote(node: block); + } + + throw new UnimplementedError('Block format $blockStyle.'); + } + + void _updateSubscriptions([ZefyrEditableText oldWidget]) { + if (oldWidget == null) { + widget.controller.addListener(_handleLocalValueChange); + focusNode.addListener(_handleFocusChange); + return; + } + + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_handleLocalValueChange); + widget.controller.addListener(_handleLocalValueChange); + _input.updateRemoteValue(widget.controller.plainTextEditingValue); + } + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChange); + widget.focusNode.addListener(_handleFocusChange); + updateKeepAlive(); + } + } + + void _cancelSubscriptions() { + _renderContext.removeListener(_handleRenderContextChange); + widget.controller.removeListener(_handleLocalValueChange); + focusNode.removeListener(_handleFocusChange); + _input.closeConnection(); + _cursorTimer.stop(); + } + + // Triggered for both text and selection changes. + void _handleLocalValueChange() { + if (widget.enabled && + widget.controller.lastChangeSource == ChangeSource.local) { + // Only request keyboard for user actions. + requestKeyboard(); + } + _input.updateRemoteValue(widget.controller.plainTextEditingValue); + _cursorTimer.startOrStop(focusNode, selection); + setState(() { + // nothing to update internally. + }); + } + + void _handleFocusChange() { + _input.openOrCloseConnection( + focusNode, widget.controller.plainTextEditingValue); + _cursorTimer.startOrStop(focusNode, selection); + updateKeepAlive(); + } + + void _handleRemoteValueChange( + int start, String deleted, String inserted, TextSelection selection) { + widget.controller + .replaceText(start, deleted.length, inserted, selection: selection); + } + + void _handleRenderContextChange() { + setState(() { + // nothing to update internally. + }); + } +} diff --git a/zefyr/lib/src/widgets/editor.dart b/zefyr/lib/src/widgets/editor.dart new file mode 100644 index 00000000..4a37337e --- /dev/null +++ b/zefyr/lib/src/widgets/editor.dart @@ -0,0 +1,164 @@ +// 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 'package:flutter/widgets.dart'; + +import 'controller.dart'; +import 'editable_text.dart'; +import 'image.dart'; +import 'scaffold.dart'; +import 'scope.dart'; +import 'theme.dart'; +import 'toolbar.dart'; + +/// Widget for editing Zefyr documents. +class ZefyrEditor extends StatefulWidget { + const ZefyrEditor({ + Key key, + @required this.controller, + @required this.focusNode, + this.autofocus: true, + this.enabled: true, + this.padding: const EdgeInsets.symmetric(horizontal: 16.0), + this.toolbarDelegate, + this.imageDelegate, + this.physics, + }) : super(key: key); + + final ZefyrController controller; + final FocusNode focusNode; + final bool autofocus; + final bool enabled; + final ZefyrToolbarDelegate toolbarDelegate; + final ZefyrImageDelegate imageDelegate; + final ScrollPhysics physics; + + /// Padding around editable area. + final EdgeInsets padding; + + @override + _ZefyrEditorState createState() => new _ZefyrEditorState(); +} + +class _ZefyrEditorState extends State { + ZefyrImageDelegate _imageDelegate; + ZefyrScope _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 context) { + 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 { + // TODO: is there a nicer way to do this? + WidgetsBinding.instance.addPostFrameCallback((_) { + _toolbarKey?.currentState?.markNeedsRebuild(); + }); + } + } + + @override + void initState() { + super.initState(); + _imageDelegate = widget.imageDelegate ?? new ZefyrDefaultImageDelegate(); + } + + @override + void didUpdateWidget(ZefyrEditor oldWidget) { + super.didUpdateWidget(oldWidget); + _scope.controller = widget.controller; + _scope.focusNode = widget.focusNode; + if (widget.imageDelegate != oldWidget.imageDelegate) { + _imageDelegate = widget.imageDelegate ?? new ZefyrDefaultImageDelegate(); + _scope.imageDelegate = _imageDelegate; + } + } + + @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 = ZefyrScope.editable( + imageDelegate: _imageDelegate, + controller: widget.controller, + focusNode: widget.focusNode, + focusScope: FocusScope.of(context), + ); + _scope.addListener(_handleChange); + } else { + final focusScope = FocusScope.of(context); + _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(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget editable = new ZefyrEditableText( + controller: _scope.controller, + focusNode: _scope.focusNode, + imageDelegate: _scope.imageDelegate, + autofocus: widget.autofocus, + enabled: widget.enabled, + padding: widget.padding, + physics: widget.physics, + ); + + return ZefyrTheme( + data: _themeData, + child: ZefyrScopeAccess( + scope: _scope, + child: editable, + ), + ); + } +} diff --git a/zefyr/lib/src/widgets/field.dart b/zefyr/lib/src/widgets/field.dart new file mode 100644 index 00000000..d23f3ca7 --- /dev/null +++ b/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/zefyr/lib/src/widgets/horizontal_rule.dart b/zefyr/lib/src/widgets/horizontal_rule.dart new file mode 100644 index 00000000..d9011f0e --- /dev/null +++ b/zefyr/lib/src/widgets/horizontal_rule.dart @@ -0,0 +1,121 @@ +// 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:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; + +import 'editable_box.dart'; + +class ZefyrHorizontalRule extends LeafRenderObjectWidget { + ZefyrHorizontalRule({@required this.node}) : assert(node != null); + + final EmbedNode node; + + @override + RenderHorizontalRule createRenderObject(BuildContext context) { + return new RenderHorizontalRule(node: node); + } + + @override + void updateRenderObject( + BuildContext context, RenderHorizontalRule renderObject) { + renderObject..node = node; + } +} + +class RenderHorizontalRule extends RenderEditableBox { + static const _kPaddingBottom = 24.0; + static const _kThickness = 3.0; + static const _kHeight = _kThickness + _kPaddingBottom; + + RenderHorizontalRule({ + @required EmbedNode node, + }) : _node = node; + + @override + EmbedNode get node => _node; + EmbedNode _node; + set node(EmbedNode value) { + if (_node == value) return; + _node = value; + markNeedsPaint(); + } + + @override + double get preferredLineHeight => size.height; + + @override + SelectionOrder get selectionOrder => SelectionOrder.background; + + @override + List getEndpointsForSelection(TextSelection selection) { + TextSelection local = getLocalSelection(selection); + if (local.isCollapsed) { + final dx = local.extentOffset == 0 ? 0.0 : size.width; + return [ + new ui.TextBox.fromLTRBD(dx, 0.0, dx, size.height, TextDirection.ltr), + ]; + } + + return [ + new ui.TextBox.fromLTRBD(0.0, 0.0, 0.0, size.height, TextDirection.ltr), + new ui.TextBox.fromLTRBD( + size.width, 0.0, size.width, size.height, TextDirection.ltr), + ]; + } + + @override + void performLayout() { + assert(constraints.hasBoundedWidth); + size = new Size(constraints.maxWidth, _kHeight); + } + + @override + void paint(PaintingContext context, Offset offset) { + final rect = new Rect.fromLTWH(0.0, 0.0, size.width, _kThickness); + final paint = new ui.Paint()..color = Colors.grey.shade200; + context.canvas.drawRect(rect.shift(offset), paint); + } + + @override + TextPosition getPositionForOffset(Offset offset) { + int position = _node.documentOffset; + + if (offset.dx > size.width / 2) { + position++; + } + return new TextPosition(offset: position); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final start = _node.documentOffset; + return new TextRange(start: start, end: start + 1); + } + + @override + void paintSelection(PaintingContext context, Offset offset, + TextSelection selection, Color selectionColor) { + final localSelection = getLocalSelection(selection); + assert(localSelection != null); + if (!localSelection.isCollapsed) { + final Paint paint = new Paint()..color = selectionColor; + final rect = new Rect.fromLTWH(0.0, 0.0, size.width, _kHeight); + context.canvas.drawRect(rect.shift(offset), paint); + } + } + + @override + Offset getOffsetForCaret(ui.TextPosition position, ui.Rect caretPrototype) { + final pos = position.offset - node.documentOffset; + Offset caretOffset = Offset.zero; + if (pos == 1) { + caretOffset = caretOffset + new Offset(size.width - 1.0, 0.0); + } + return caretOffset; + } +} diff --git a/zefyr/lib/src/widgets/image.dart b/zefyr/lib/src/widgets/image.dart new file mode 100644 index 00000000..be096d06 --- /dev/null +++ b/zefyr/lib/src/widgets/image.dart @@ -0,0 +1,236 @@ +// 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:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'editable_box.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); +} + +class ZefyrDefaultImageDelegate implements ZefyrImageDelegate { + @override + Widget buildImage(BuildContext context, String imageSource) { + final file = new File.fromUri(Uri.parse(imageSource)); + final image = new FileImage(file); + return new Image(image: image); + } + + @override + Future pickImage(ImageSource source) async { + final file = await ImagePicker.pickImage(source: source); + if (file == null) return null; + return file.uri.toString(); + } +} + +class ZefyrImage extends StatefulWidget { + const ZefyrImage({Key key, @required this.node, @required this.delegate}) + : super(key: key); + + final EmbedNode node; + final ZefyrImageDelegate delegate; + + @override + _ZefyrImageState createState() => _ZefyrImageState(); +} + +class _ZefyrImageState extends State { + String get imageSource { + EmbedAttribute attribute = widget.node.style.get(NotusAttribute.embed); + return attribute.value['source'] as String; + } + + @override + Widget build(BuildContext context) { + final image = widget.delegate.buildImage(context, imageSource); + return _EditableImage( + child: image, + node: widget.node, + ); + } +} + +class _EditableImage extends SingleChildRenderObjectWidget { + _EditableImage({@required Widget child, @required this.node}) + : assert(node != null), + super(child: child); + + final EmbedNode node; + + @override + RenderEditableImage createRenderObject(BuildContext context) { + return new RenderEditableImage(node: node); + } + + @override + void updateRenderObject( + BuildContext context, RenderEditableImage renderObject) { + renderObject..node = node; + } +} + +class RenderEditableImage extends RenderBox + with RenderObjectWithChildMixin, RenderProxyBoxMixin + implements RenderEditableBox { + static const kPaddingBottom = 24.0; + + RenderEditableImage({ + RenderImage child, + @required EmbedNode node, + }) : _node = node { + this.child = child; + } + + @override + EmbedNode get node => _node; + EmbedNode _node; + void set node(EmbedNode value) { + _node = value; + } + + // TODO: Customize caret height offset instead of adjusting here by 2px. + @override + double get preferredLineHeight => size.height - kPaddingBottom + 2.0; + + @override + SelectionOrder get selectionOrder => SelectionOrder.foreground; + + @override + TextSelection getLocalSelection(TextSelection documentSelection) { + if (!intersectsWithSelection(documentSelection)) return null; + + int nodeBase = node.documentOffset; + int nodeExtent = nodeBase + node.length; + int base = math.max(0, documentSelection.baseOffset - nodeBase); + int extent = + math.min(documentSelection.extentOffset, nodeExtent) - nodeBase; + return documentSelection.copyWith(baseOffset: base, extentOffset: extent); + } + + @override + List getEndpointsForSelection(TextSelection selection) { + TextSelection local = getLocalSelection(selection); + if (local.isCollapsed) { + final dx = local.extentOffset == 0 ? _childOffset.dx : size.width; + return [ + new ui.TextBox.fromLTRBD( + dx, 0.0, dx, size.height - kPaddingBottom, TextDirection.ltr), + ]; + } + + final rect = _childRect; + return [ + new ui.TextBox.fromLTRBD( + rect.left, rect.top, rect.left, rect.bottom, TextDirection.ltr), + new ui.TextBox.fromLTRBD( + rect.right, rect.top, rect.right, rect.bottom, TextDirection.ltr), + ]; + } + + @override + TextPosition getPositionForOffset(Offset offset) { + int position = _node.documentOffset; + + if (offset.dx > size.width / 2) { + position++; + } + return new TextPosition(offset: position); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final start = _node.documentOffset; + return new TextRange(start: start, end: start + 1); + } + + @override + bool intersectsWithSelection(TextSelection selection) { + final int base = node.documentOffset; + final int extent = base + node.length; + return base <= selection.extentOffset && selection.baseOffset <= extent; + } + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + final pos = position.offset - node.documentOffset; + Offset caretOffset = _childOffset - new Offset(kHorizontalPadding, 0.0); + if (pos == 1) { + caretOffset = caretOffset + + new Offset(_lastChildSize.width + kHorizontalPadding, 0.0); + } + return caretOffset; + } + + @override + void paintSelection(PaintingContext context, Offset offset, + TextSelection selection, Color selectionColor) { + final localSelection = getLocalSelection(selection); + assert(localSelection != null); + if (!localSelection.isCollapsed) { + final Paint paint = new Paint() + ..color = selectionColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0; + final rect = new Rect.fromLTWH( + 0.0, 0.0, _lastChildSize.width, _lastChildSize.height); + context.canvas.drawRect(rect.shift(offset + _childOffset), paint); + } + } + + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset + _childOffset); + } + + static const double kHorizontalPadding = 1.0; + + Size _lastChildSize; + + Offset get _childOffset { + final dx = (size.width - _lastChildSize.width) / 2 + kHorizontalPadding; + final dy = (size.height - _lastChildSize.height - kPaddingBottom) / 2; + return new Offset(dx, dy); + } + + Rect get _childRect { + return new Rect.fromLTWH(_childOffset.dx, _childOffset.dy, + _lastChildSize.width, _lastChildSize.height); + } + + @override + void performLayout() { + assert(constraints.hasBoundedWidth); + if (child != null) { + // Make constraints use 16:9 aspect ratio. + final width = constraints.maxWidth - kHorizontalPadding * 2; + final childConstraints = constraints.copyWith( + minWidth: 0.0, + maxWidth: width, + minHeight: 0.0, + maxHeight: (width * 9 / 16).floorToDouble(), + ); + child.layout(childConstraints, parentUsesSize: true); + _lastChildSize = child.size; + size = new Size( + constraints.maxWidth, _lastChildSize.height + kPaddingBottom); + } else { + performResize(); + } + } +} diff --git a/zefyr/lib/src/widgets/input.dart b/zefyr/lib/src/widgets/input.dart new file mode 100644 index 00000000..d006d976 --- /dev/null +++ b/zefyr/lib/src/widgets/input.dart @@ -0,0 +1,198 @@ +// 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 'package:flutter/services.dart'; +import 'package:flutter/src/foundation/diagnostics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:zefyr/util.dart'; + +typedef RemoteValueChanged = Function( + int start, String deleted, String inserted, TextSelection selection); + +class InputConnectionController implements TextInputClient { + InputConnectionController(this.onValueChanged) + : assert(onValueChanged != null); + + // + // New public members + // + + final RemoteValueChanged onValueChanged; + + /// Returns `true` if there is open input connection. + bool get hasConnection => + _textInputConnection != null && _textInputConnection.attached; + + /// Opens or closes input connection based on the current state of + /// [focusNode] and [value]. + void openOrCloseConnection(FocusNode focusNode, TextEditingValue value) { + if (focusNode.hasFocus && focusNode.consumeKeyboardToken()) { + openConnection(value); + } else if (!focusNode.hasFocus) { + closeConnection(); + } + } + + void openConnection(TextEditingValue value) { + if (!hasConnection) { + _lastKnownRemoteTextEditingValue = value; + _textInputConnection = TextInput.attach( + this, + new TextInputConfiguration( + inputType: TextInputType.multiline, + obscureText: false, + autocorrect: true, + inputAction: TextInputAction.newline, + textCapitalization: TextCapitalization.sentences, + ), + )..setEditingState(value); + _sentRemoteValues.add(value); + } + _textInputConnection.show(); + } + + /// Closes input connection if it's currently open. Otherwise does nothing. + void closeConnection() { + if (hasConnection) { + _textInputConnection.close(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + _sentRemoteValues.clear(); + } + } + + /// Updates remote value based on current state of [document] and + /// [selection]. + /// + /// This method may not actually send an update to native side if it thinks + /// remote value is up to date or identical. + void updateRemoteValue(TextEditingValue value) { + if (!hasConnection) return; + + // Since we don't keep track of composing range in value provided by + // ZefyrController we need to add it here manually before comparing + // with the last known remote value. + // It is important to prevent excessive remote updates as it can cause + // race conditions. + final actualValue = value.copyWith( + composing: _lastKnownRemoteTextEditingValue.composing, + ); + + if (actualValue == _lastKnownRemoteTextEditingValue) return; + + bool shouldRemember = value.text != _lastKnownRemoteTextEditingValue.text; + _lastKnownRemoteTextEditingValue = actualValue; + _textInputConnection.setEditingState(actualValue); + if (shouldRemember) { + // Only keep track if text changed (selection changes are not relevant) + _sentRemoteValues.add(actualValue); + } + } + + // + // Overridden members + // + + @override + void performAction(TextInputAction action) { + // no-op + } + + @override + void updateEditingValue(TextEditingValue value) { + if (_sentRemoteValues.contains(value)) { + /// There is a race condition in Flutter text input plugin where sending + /// updates to native side too often results in broken behavior. + /// TextInputConnection.setEditingValue is an async call to native side. + /// For each such call native side _always_ sends update which triggers + /// this method (updateEditingValue) with the same value we've sent it. + /// If multiple calls to setEditingValue happen too fast and we only + /// track the last sent value then there is no way for us to filter out + /// automatic callbacks from native side. + /// Therefore we have to keep track of all values we send to the native + /// side and when we see this same value appear here we skip it. + /// This is fragile but it's probably the only available option. + _sentRemoteValues.remove(value); + return; + } + + if (_lastKnownRemoteTextEditingValue == value) { + // There is no difference between this value and the last known value. + return; + } + + // Check if only composing range changed. + if (_lastKnownRemoteTextEditingValue.text == value.text && + _lastKnownRemoteTextEditingValue.selection == value.selection) { + // This update only modifies composing range. Since we don't keep track + // of composing range in Zefyr we just need to update last known value + // here. + // Note: this check fixes an issue on Android when it sends + // composing updates separately from regular changes for text and + // selection. + _lastKnownRemoteTextEditingValue = value; + return; + } + + // Note Flutter (unintentionally?) silences errors occurred during + // text input update, so we have to report it ourselves. + // For more details see https://github.com/flutter/flutter/issues/19191 + // TODO: remove try-catch when/if Flutter stops silencing these errors. + try { + final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue; + _lastKnownRemoteTextEditingValue = value; + final oldText = effectiveLastKnownValue.text; + final text = value.text; + final cursorPosition = value.selection.extentOffset; + final diff = fastDiff(oldText, text, cursorPosition); + onValueChanged(diff.start, diff.deleted, diff.inserted, value.selection); + } catch (e, trace) { + FlutterError.reportError(new FlutterErrorDetails( + exception: e, + stack: trace, + library: 'Zefyr', +// context: 'while updating editing value', + context: new TextNode() + )); + rethrow; + } + } + + + // + // Private members + // + + final List _sentRemoteValues = []; + TextInputConnection _textInputConnection; + TextEditingValue _lastKnownRemoteTextEditingValue; + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + // TODO: implement updateFloatingCursor + } +} + +class TextNode extends DiagnosticsNode{ + @override + List getChildren() { + // TODO: implement getChildren + return null; + } + + @override + List getProperties() { + // TODO: implement getProperties + return null; + } + + @override + String toDescription({TextTreeConfiguration parentConfiguration}) { + // TODO: implement toDescription + return null; + } + + @override + Object get value => 'while updating editing value'; + +} diff --git a/zefyr/lib/src/widgets/list.dart b/zefyr/lib/src/widgets/list.dart new file mode 100644 index 00000000..a3479e58 --- /dev/null +++ b/zefyr/lib/src/widgets/list.dart @@ -0,0 +1,86 @@ +// 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 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'common.dart'; +import 'paragraph.dart'; +import 'theme.dart'; + +/// Represents number lists and bullet lists in a Zefyr editor. +class ZefyrList extends StatelessWidget { + const ZefyrList({Key key, @required this.node}) : super(key: key); + + final BlockNode node; + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context); + List items = []; + int index = 1; + for (var line in node.children) { + items.add(_buildItem(line, index)); + index++; + } + + final isNumberList = + node.style.get(NotusAttribute.block) == NotusAttribute.block.numberList; + EdgeInsets padding = isNumberList + ? theme.blockTheme.numberList.padding + : theme.blockTheme.bulletList.padding; + padding = padding.copyWith(left: theme.indentSize); + + return new Padding( + padding: padding, + child: new Column(children: items), + ); + } + + Widget _buildItem(Node node, int index) { + LineNode line = node; + return new ZefyrListItem(index: index, node: line); + } +} + +/// An item in a [ZefyrList]. +class ZefyrListItem extends StatelessWidget { + ZefyrListItem({Key key, this.index, this.node}) : super(key: key); + + final int index; + final LineNode node; + + @override + Widget build(BuildContext context) { + final BlockNode block = node.parent; + final style = block.style.get(NotusAttribute.block); + final theme = ZefyrTheme.of(context); + final bulletText = + (style == NotusAttribute.block.bulletList) ? '•' : '$index.'; + + TextStyle textStyle; + Widget content; + EdgeInsets padding; + + if (node.style.contains(NotusAttribute.heading)) { + final headingTheme = ZefyrHeading.themeOf(node, context); + textStyle = headingTheme.textStyle; + padding = headingTheme.padding; + content = new ZefyrHeading(node: node); + } else { + textStyle = theme.paragraphTheme.textStyle; + content = new RawZefyrLine(node: node, style: textStyle); + } + + Widget bullet = + SizedBox(width: 24.0, child: Text(bulletText, style: textStyle)); + if (padding != null) { + bullet = Padding(padding: padding, child: bullet); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [bullet, Expanded(child: content)], + ); + } +} diff --git a/zefyr/lib/src/widgets/paragraph.dart b/zefyr/lib/src/widgets/paragraph.dart new file mode 100644 index 00000000..222b5bae --- /dev/null +++ b/zefyr/lib/src/widgets/paragraph.dart @@ -0,0 +1,68 @@ +// 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 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'common.dart'; +import 'theme.dart'; + +/// Represents regular paragraph line in a Zefyr editor. +class ZefyrParagraph extends StatelessWidget { + ZefyrParagraph({Key key, @required this.node, this.blockStyle}) + : super(key: key); + + final LineNode node; + final TextStyle blockStyle; + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context); + TextStyle style = theme.paragraphTheme.textStyle; + if (blockStyle != null) { + style = style.merge(blockStyle); + } + return new RawZefyrLine( + node: node, + style: style, + padding: theme.paragraphTheme.padding, + ); + } +} + +/// Represents heading-styled line in [ZefyrEditor]. +class ZefyrHeading extends StatelessWidget { + ZefyrHeading({Key key, @required this.node, this.blockStyle}) + : assert(node.style.contains(NotusAttribute.heading)), + super(key: key); + + final LineNode node; + final TextStyle blockStyle; + + @override + Widget build(BuildContext context) { + final theme = themeOf(node, context); + TextStyle style = theme.textStyle; + if (blockStyle != null) { + style = style.merge(blockStyle); + } + return new RawZefyrLine( + node: node, + style: style, + padding: theme.padding, + ); + } + + static StyleTheme themeOf(LineNode node, BuildContext context) { + final theme = ZefyrTheme.of(context); + final style = node.style.get(NotusAttribute.heading); + if (style == NotusAttribute.heading.level1) { + return theme.headingTheme.level1; + } else if (style == NotusAttribute.heading.level2) { + return theme.headingTheme.level2; + } else if (style == NotusAttribute.heading.level3) { + return theme.headingTheme.level3; + } + throw new UnimplementedError('Unsupported heading style $style'); + } +} diff --git a/zefyr/lib/src/widgets/quote.dart b/zefyr/lib/src/widgets/quote.dart new file mode 100644 index 00000000..a9eacd13 --- /dev/null +++ b/zefyr/lib/src/widgets/quote.dart @@ -0,0 +1,55 @@ +// 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 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'paragraph.dart'; +import 'theme.dart'; + +/// Represents a quote block in a Zefyr editor. +class ZefyrQuote extends StatelessWidget { + const ZefyrQuote({Key key, @required this.node}) : super(key: key); + + final BlockNode node; + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context); + final style = theme.blockTheme.quote.textStyle; + List items = []; + for (var line in node.children) { + items.add(_buildLine(line, style, theme.indentSize)); + } + + return Padding( + padding: theme.blockTheme.quote.padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: items, + ), + ); + } + + Widget _buildLine(Node node, TextStyle blockStyle, double indentSize) { + LineNode line = node; + + Widget content; + if (line.style.contains(NotusAttribute.heading)) { + content = new ZefyrHeading(node: line, blockStyle: blockStyle); + } else { + content = new ZefyrParagraph(node: line, blockStyle: blockStyle); + } + + final row = Row(children: [Expanded(child: content)]); + return Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(width: 4.0, color: Colors.grey.shade300), + ), + ), + padding: EdgeInsets.only(left: indentSize), + child: row, + ); + } +} diff --git a/zefyr/lib/src/widgets/render_context.dart b/zefyr/lib/src/widgets/render_context.dart new file mode 100644 index 00000000..1e88aa53 --- /dev/null +++ b/zefyr/lib/src/widgets/render_context.dart @@ -0,0 +1,146 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'editable_box.dart'; + +/// Registry of all [RenderEditableProxyBox]es inside a [ZefyrEditableText]. +/// +/// Provides access to all currently active [RenderEditableProxyBox] +/// instances of a [ZefyrEditableText]. +/// +/// Use [boxForTextOffset] or [boxForGlobalPoint] to retrieve a +/// specific box. +/// +/// The [addBox], [removeBox] and [markDirty] are intended to be +/// only used by [RenderEditableProxyBox] objects to register with a rendering +/// context. +/// +/// ### Life cycle details +/// +/// When a box object is attached to rendering pipeline it registers +/// itself with a render scope by calling [addBox]. At this point the context +/// treats this object as "dirty" and query methods like [boxForTextOffset] +/// still exclude this object from returned results. +/// +/// When this box considers itself initialized it calls [markDirty] with +/// `isDirty` set to `false` which activates it. At this point query methods +/// include this object in results. +/// +/// When a box is rebuilt it may deactivate itself by calling [markDirty] +/// again. +/// +/// When a box is detached from rendering pipeline it unregisters +/// itself by calling [removeBox]. +class ZefyrRenderContext extends ChangeNotifier { + final Set _dirtyBoxes = new Set(); + final Set _activeBoxes = new Set(); + + Set get dirty => _dirtyBoxes; + Set get active => _activeBoxes; + + bool _disposed = false; + + /// Adds [box] to this context. The box is considered "dirty" at + /// this point and is not included in query results of `boxFor*` + /// methods. + void addBox(RenderEditableProxyBox box) { + assert(!_disposed); + _dirtyBoxes.add(box); + } + + /// Removes [box] from this render context. + void removeBox(RenderEditableProxyBox box) { + assert(!_disposed); + _dirtyBoxes.remove(box); + _activeBoxes.remove(box); + notifyListeners(); + } + + void markDirty(RenderEditableProxyBox box, bool isDirty) { + assert(!_disposed); + + var collection = isDirty ? _dirtyBoxes : _activeBoxes; + if (collection.contains(box)) return; + + if (isDirty) { + _activeBoxes.remove(box); + _dirtyBoxes.add(box); + } else { + _dirtyBoxes.remove(box); + _activeBoxes.add(box); + } + notifyListeners(); + } + + /// Returns box containing character at specified document [offset]. + RenderEditableProxyBox boxForTextOffset(int offset) { + assert(!_disposed); + return _activeBoxes.firstWhere( + (p) => p.node.containsOffset(offset), + orElse: _null, + ); + } + + /// Returns box located at specified global [point] on the screen or + /// `null`. + RenderEditableProxyBox boxForGlobalPoint(Offset point) { + assert(!_disposed); + return _activeBoxes.firstWhere((p) { + final localPoint = p.globalToLocal(point); + return p.size.contains(localPoint); + }, orElse: _null); + } + + /// Returns closest render box to the specified global [point]. + /// + /// If [point] is inside of one of active render boxes that box is returned. + /// If no box found this method checks if [point] is to the left or to the right + /// side of a box, e.g. if vertical offset of this point is inside of one of + /// the active boxes. If it is then that box is returned. If not then this + /// method picks a box with shortest vertical distance to this [point]. + RenderEditableProxyBox closestBoxForGlobalPoint(Offset point) { + assert(!_disposed); + if (_activeBoxes.isEmpty) return null; + RenderEditableProxyBox box = boxForGlobalPoint(point); + if (box != null) return box; + + box = _activeBoxes.firstWhere((p) { + final localPoint = p.globalToLocal(point); + return (localPoint.dy >= 0 && localPoint.dy < p.size.height); + }, orElse: _null); + if (box != null) return box; + + box = _activeBoxes.map((p) { + final localPoint = p.globalToLocal(point); + final distance = localPoint.dy - p.size.height; + return new MapEntry(distance.abs(), p); + }).reduce((a, b) { + return (a.key <= b.key) ? a : b; + }).value; + + return box; + } + + static Null _null() => null; + + @override + void dispose() { + _disposed = true; + _activeBoxes.clear(); + _dirtyBoxes.clear(); + super.dispose(); + } + + @override + void notifyListeners() { + /// Ensures listeners are not notified during rendering phase where they + /// cannot react by updating their state or rebuilding. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_disposed) return; + super.notifyListeners(); + }); + } +} diff --git a/zefyr/lib/src/widgets/rich_text.dart b/zefyr/lib/src/widgets/rich_text.dart new file mode 100644 index 00000000..f2eff9c9 --- /dev/null +++ b/zefyr/lib/src/widgets/rich_text.dart @@ -0,0 +1,223 @@ +// 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:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:notus/notus.dart'; + +import 'caret.dart'; +import 'editable_box.dart'; + +/// Represents single paragraph of Zefyr rich-text. +class ZefyrRichText extends LeafRenderObjectWidget { + ZefyrRichText({ + @required this.node, + @required this.text, + }) : assert(node != null && text != null); + + final LineNode node; + final TextSpan text; + + @override + RenderObject createRenderObject(BuildContext context) { + return new RenderZefyrParagraph( + text, + node: node, + textDirection: Directionality.of(context), + ); + } + + @override + void updateRenderObject( + BuildContext context, RenderZefyrParagraph renderObject) { + renderObject + ..text = text + ..node = node; + } +} + +class RenderZefyrParagraph extends RenderParagraph + implements RenderEditableBox { + RenderZefyrParagraph( + TextSpan text, { + @required LineNode node, + TextAlign textAlign: TextAlign.start, + @required TextDirection textDirection, + bool softWrap: true, + TextOverflow overflow: TextOverflow.clip, + double textScaleFactor: 1.0, + int maxLines, + }) : _node = node, + _prototypePainter = new TextPainter( + text: new TextSpan(text: '.', style: text.style), + textAlign: textAlign, + textDirection: textDirection, + textScaleFactor: textScaleFactor, + ), + super( + text, + textAlign: textAlign, + textDirection: textDirection, + softWrap: softWrap, + overflow: overflow, + textScaleFactor: textScaleFactor, + maxLines: maxLines, + ); + + LineNode get node => _node; + LineNode _node; + void set node(LineNode value) { + _node = value; + } + + @override + double get preferredLineHeight => _prototypePainter.height; + + @override + SelectionOrder get selectionOrder => SelectionOrder.background; + + @override + TextSelection getLocalSelection(TextSelection documentSelection) { + if (!intersectsWithSelection(documentSelection)) return null; + + int nodeBase = node.documentOffset; + int nodeExtent = nodeBase + node.length; + int base = math.max(0, documentSelection.baseOffset - nodeBase); + int extent = + math.min(documentSelection.extentOffset, nodeExtent) - nodeBase; + return documentSelection.copyWith(baseOffset: base, extentOffset: extent); + } + + @override + TextPosition getPositionForOffset(Offset offset) { + final position = super.getPositionForOffset(offset); + return new TextPosition( + offset: _node.documentOffset + position.offset, + affinity: position.affinity, + ); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final localPosition = new TextPosition( + offset: position.offset - _node.documentOffset, + affinity: position.affinity, + ); + final localRange = super.getWordBoundary(localPosition); + return new TextRange( + start: _node.documentOffset + localRange.start, + end: _node.documentOffset + localRange.end, + ); + } + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + final localPosition = new TextPosition( + offset: position.offset - _node.documentOffset, + affinity: position.affinity, + ); + return super.getOffsetForCaret(localPosition, caretPrototype); + } + + // This method works around some issues in getBoxesForSelection and handles + // edge-case with our TextSpan objects not having last line-break character. + @override + List getEndpointsForSelection(TextSelection selection) { + TextSelection local = getLocalSelection(selection); + if (local.isCollapsed) { + final caret = CursorPainter.buildPrototype(preferredLineHeight); + final offset = getOffsetForCaret(local.extent, caret); + return [ + new ui.TextBox.fromLTRBD( + offset.dx, + offset.dy, + offset.dx, + offset.dy + caret.height, + TextDirection.ltr, + ) + ]; + } + + int isBaseShifted = 0; + bool isExtentShifted = false; + if (local.baseOffset == node.length - 1 && local.baseOffset > 0) { + // Since we exclude last line-break from rendered TextSpan we have to + // handle end-of-line selection explicitly. + local = local.copyWith(baseOffset: local.baseOffset - 1); + isBaseShifted = -1; + } else if (local.baseOffset == 0 && local.isCollapsed) { + // This takes care of beginning of line position. + local = local.copyWith(baseOffset: local.baseOffset + 1); + isBaseShifted = 1; + } + if (text.codeUnitAt(local.extentOffset - 1) == 0xA) { + // This takes care of the rest end-of-line scenarios, where there are + // actually line-breaks in the TextSpan (e.g. in code blocks). + local = local.copyWith(extentOffset: local.extentOffset + 1); + isExtentShifted = true; + } + final result = getBoxesForSelection(local).toList(); + if (isBaseShifted != 0) { + final box = result.first; + final dx = isBaseShifted == -1 ? box.right : box.left; + result.removeAt(0); + result.insert(0, + new ui.TextBox.fromLTRBD(dx, box.top, dx, box.bottom, box.direction)); + } + if (isExtentShifted) { + final box = result.last; + result.removeLast; + result.add(new ui.TextBox.fromLTRBD( + box.left, box.top, box.left, box.bottom, box.direction)); + } + return result; + } + + @override + void set text(InlineSpan value) { + _prototypePainter.text = new TextSpan(text: '.', style: value.style); + _selectionRects = null; + super.text = value; + } + + @override + void performLayout() { + super.performLayout(); + _prototypePainter.layout( + minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + } + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + } + + final TextPainter _prototypePainter; + List _selectionRects; + + /// Returns `true` if this paragraph intersects with document [selection]. + @override + bool intersectsWithSelection(TextSelection selection) { + final int base = node.documentOffset; + final int extent = base + node.length; + return base <= selection.extentOffset && selection.baseOffset <= extent; + } + + TextSelection _lastPaintedSelection; + @override + void paintSelection(PaintingContext context, Offset offset, + TextSelection selection, Color selectionColor) { + if (_lastPaintedSelection != selection) { + _selectionRects = null; + } + _selectionRects ??= getBoxesForSelection(getLocalSelection(selection)); + final Paint paint = new Paint()..color = selectionColor; + for (ui.TextBox box in _selectionRects) { + context.canvas.drawRect(box.toRect().shift(offset), paint); + } + _lastPaintedSelection = selection; + } +} diff --git a/zefyr/lib/src/widgets/scaffold.dart b/zefyr/lib/src/widgets/scaffold.dart new file mode 100644 index 00000000..f711070e --- /dev/null +++ b/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/zefyr/lib/src/widgets/scope.dart b/zefyr/lib/src/widgets/scope.dart new file mode 100644 index 00000000..2853a8a2 --- /dev/null +++ b/zefyr/lib/src/widgets/scope.dart @@ -0,0 +1,232 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'controller.dart'; +import 'cursor_timer.dart'; +import 'editor.dart'; +import 'image.dart'; +import 'render_context.dart'; +import 'view.dart'; + +/// Provides access to shared state of [ZefyrEditor] or [ZefyrView]. +/// +/// A scope object can be created by an editable widget like [ZefyrEditor] in +/// which case it provides access to editing state, including focus nodes, +/// selection and such. Editable scope can be created using +/// [ZefyrScope.editable] constructor. +/// +/// If a scope object is created by a view-only widget like [ZefyrView] then +/// it only provides access to [imageDelegate]. +/// +/// Can be retrieved using [ZefyrScope.of]. +class ZefyrScope extends ChangeNotifier { + /// Creates a view-only scope. + /// + /// Normally used in [ZefyrView]. + ZefyrScope.view({@required ZefyrImageDelegate imageDelegate}) + : assert(imageDelegate != null), + isEditable = false, + _imageDelegate = imageDelegate; + + /// Creates editable scope. + /// + /// Normally used in [ZefyrEditor]. + ZefyrScope.editable({ + @required ZefyrController controller, + @required ZefyrImageDelegate imageDelegate, + @required FocusNode focusNode, + @required FocusScopeNode focusScope, + }) : assert(controller != null), + assert(imageDelegate != null), + assert(focusNode != null), + assert(focusScope != null), + isEditable = true, + _controller = controller, + _imageDelegate = imageDelegate, + _focusNode = focusNode, + _focusScope = focusScope, + _cursorTimer = CursorTimer(), + _renderContext = ZefyrRenderContext() { + _selectionStyle = _controller.getSelectionStyle(); + _selection = _controller.selection; + _controller.addListener(_handleControllerChange); + _focusNode.addListener(_handleFocusChange); + } + + static ZefyrScope of(BuildContext context) { + final ZefyrScopeAccess widget = + context.inheritFromWidgetOfExactType(ZefyrScopeAccess); + return widget.scope; + } + + ZefyrImageDelegate _imageDelegate; + ZefyrImageDelegate get imageDelegate => _imageDelegate; + set imageDelegate(ZefyrImageDelegate value) { + assert(value != null); + if (_imageDelegate != value) { + _imageDelegate = value; + notifyListeners(); + } + } + + ZefyrController _controller; + ZefyrController get controller => _controller; + set controller(ZefyrController value) { + assert(isEditable && value != null); + if (_controller != value) { + _controller.removeListener(_handleControllerChange); + _controller = value; + _selectionStyle = _controller.getSelectionStyle(); + _selection = _controller.selection; + _controller.addListener(_handleControllerChange); + notifyListeners(); + } + } + + FocusNode _focusNode; + FocusNode get focusNode => _focusNode; + set focusNode(FocusNode value) { + assert(isEditable && value != null); + if (_focusNode != value) { + _focusNode.removeListener(_handleFocusChange); + _focusNode = value; + _focusNode.addListener(_handleFocusChange); + notifyListeners(); + } + } + + FocusScopeNode _focusScope; + FocusScopeNode get focusScope => _focusScope; + set focusScope(FocusScopeNode value) { + assert(isEditable && value != null); + if (_focusScope != value) { + _focusScope = value; + } + } + + CursorTimer _cursorTimer; + CursorTimer get cursorTimer => _cursorTimer; + ValueNotifier get showCursor => cursorTimer.value; + + ZefyrRenderContext _renderContext; + ZefyrRenderContext get renderContext => _renderContext; + + NotusStyle get selectionStyle => _selectionStyle; + NotusStyle _selectionStyle; + TextSelection get selection => _selection; + TextSelection _selection; + + bool _disposed = false; + FocusNode _toolbarFocusNode; + + /// Whether this scope is backed by editable Zefyr widgets or read-only view. + /// + /// Returns `true` if this scope provides Zefyr interface that allows editing + /// (e.g. created by [ZefyrEditor]). Returns `false` if this scope provides + /// read-only view (e.g. created by [ZefyrView]). + /// + /// Editable scope provides access to corresponding [controller], [focusNode], + /// [focusScope], [showCursor], [renderContext] and other shared objects. For + /// non-editable scopes these are set to `null`. You can still access + /// objects which are not dependent on editing flow, e.g. [imageDelegate]. + final bool isEditable; + + set toolbarFocusNode(FocusNode node) { + assert(isEditable); + 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(isEditable); + assert(!_disposed); + if (_focusNode.hasFocus) { + return FocusOwner.editor; + } else if (_toolbarFocusNode?.hasFocus == true) { + return FocusOwner.toolbar; + } else { + return FocusOwner.none; + } + } + + void updateSelection(TextSelection value, + {ChangeSource source: ChangeSource.remote}) { + assert(isEditable); + assert(!_disposed); + _controller.updateSelection(value, source: source); + } + + void formatSelection(NotusAttribute value) { + assert(isEditable); + assert(!_disposed); + _controller.formatSelection(value); + } + + void focus() { + assert(isEditable); + assert(!_disposed); + _focusScope.requestFocus(_focusNode); + } + + void hideKeyboard() { + assert(isEditable); + assert(!_disposed); + _focusNode.unfocus(); + } + + @override + void dispose() { + assert(!_disposed); + _controller?.removeListener(_handleControllerChange); + _focusNode?.removeListener(_handleFocusChange); + _disposed = true; + super.dispose(); + } + + void _handleControllerChange() { + assert(!_disposed); + final attrs = _controller.getSelectionStyle(); + final selection = _controller.selection; + if (_selectionStyle != attrs || _selection != selection) { + _selectionStyle = attrs; + _selection = 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(); + } + + @override + String toString() { + return '$ZefyrScope#${shortHash(this)}'; + } +} + +class ZefyrScopeAccess extends InheritedWidget { + final ZefyrScope scope; + + ZefyrScopeAccess({Key key, @required this.scope, @required Widget child}) + : super(key: key, child: child); + + @override + bool updateShouldNotify(ZefyrScopeAccess oldWidget) { + return scope != oldWidget.scope; + } +} diff --git a/zefyr/lib/src/widgets/selection.dart b/zefyr/lib/src/widgets/selection.dart new file mode 100644 index 00000000..febbd661 --- /dev/null +++ b/zefyr/lib/src/widgets/selection.dart @@ -0,0 +1,512 @@ +// 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:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:notus/notus.dart'; +import 'package:zefyr/util.dart'; + +import 'controller.dart'; +import 'editable_box.dart'; +import 'scope.dart'; + +RenderEditableBox _getEditableBox(HitTestResult result) { + for (var entry in result.path) { + if (entry.target is RenderEditableBox) { + return entry.target as RenderEditableBox; + } + } + return null; +} + +/// Selection overlay controls selection handles and other gestures. +class ZefyrSelectionOverlay extends StatefulWidget { + const ZefyrSelectionOverlay({ + Key key, + @required this.controller, + @required this.controls, + @required this.overlay, + }) : super(key: key); + + final ZefyrController controller; + final TextSelectionControls controls; + final OverlayState overlay; + + @override + _ZefyrSelectionOverlayState createState() => + new _ZefyrSelectionOverlayState(); +} + +class _ZefyrSelectionOverlayState extends State + implements TextSelectionDelegate { + @override + TextEditingValue get textEditingValue => + widget.controller.plainTextEditingValue; + + set textEditingValue(TextEditingValue value) { + final cursorPosition = value.selection.extentOffset; + final oldText = widget.controller.document.toPlainText(); + final newText = value.text; + final diff = fastDiff(oldText, newText, cursorPosition); + widget.controller.replaceText( + diff.start, diff.deleted.length, diff.inserted, + selection: value.selection); + } + + @override + void bringIntoView(ui.TextPosition position) { + // TODO: implement bringIntoView + } + + bool get isToolbarVisible => _toolbar != null; + bool get isToolbarHidden => _toolbar == null; + + @override + void hideToolbar() { + _didCaretTap = false; // reset double tap. + _toolbar?.remove(); + _toolbar = null; + _toolbarController.stop(); + } + + void showToolbar() { + final scope = ZefyrScope.of(context); + assert(scope != null); + final toolbarOpacity = _toolbarController.view; + _toolbar = new OverlayEntry( + builder: (context) => new FadeTransition( + opacity: toolbarOpacity, + child: new _SelectionToolbar( + scope: scope, + controls: widget.controls, + delegate: this, + ), + ), + ); + widget.overlay.insert(_toolbar); + _toolbarController.forward(from: 0.0); + } + + // + // Overridden members of State + // + + @override + void initState() { + super.initState(); + _toolbarController = new AnimationController( + duration: _kFadeDuration, vsync: widget.overlay); + } + + static const Duration _kFadeDuration = const Duration(milliseconds: 150); + + @override + void didUpdateWidget(ZefyrSelectionOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.overlay != widget.overlay) { + hideToolbar(); + _toolbarController.dispose(); + _toolbarController = new AnimationController( + duration: _kFadeDuration, vsync: widget.overlay); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final editor = ZefyrScope.of(context); + if (_editor != editor) { + _editor?.removeListener(_handleChange); + _editor = editor; + _editor.addListener(_handleChange); + _selection = _editor.selection; + _focusOwner = _editor.focusOwner; + } + } + + @override + void dispose() { + _editor.removeListener(_handleChange); + hideToolbar(); + _toolbarController.dispose(); + _toolbarController = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final overlay = new GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: _handleTapDown, + onTap: _handleTap, + onTapCancel: _handleTapCancel, + onLongPress: _handleLongPress, + child: new Stack( + fit: StackFit.expand, + children: [ + new SelectionHandleDriver( + position: _SelectionHandlePosition.base, + controls: widget.controls, + ), + new SelectionHandleDriver( + position: _SelectionHandlePosition.extent, + controls: widget.controls, + ), + ], + ), + ); + return new Container(child: overlay); + } + + // + // Private members + // + + /// Global position of last TapDown event. + Offset _lastTapDownPosition; + + /// Global position of last TapDown which is potentially a long press. + Offset _longPressPosition; + + OverlayEntry _toolbar; + AnimationController _toolbarController; + + ZefyrScope _editor; + TextSelection _selection; + FocusOwner _focusOwner; + + bool _didCaretTap = false; + + void _handleChange() { + if (_selection != _editor.selection || _focusOwner != _editor.focusOwner) { + _updateToolbar(); + } + } + + void _updateToolbar() { + if (!mounted) { + return; + } + + final selection = _editor.selection; + final focusOwner = _editor.focusOwner; + setState(() { + if (focusOwner != FocusOwner.editor) { + hideToolbar(); + } else { + if (_selection != selection) { + if (selection.isCollapsed && isToolbarVisible) hideToolbar(); + _toolbar?.markNeedsBuild(); + if (!selection.isCollapsed && isToolbarHidden) showToolbar(); + } else { + if (!selection.isCollapsed && isToolbarHidden) { + showToolbar(); + } else if (isToolbarVisible) { + _toolbar?.markNeedsBuild(); + } + } + } + _selection = selection; + _focusOwner = focusOwner; + }); + } + + void _handleTapDown(TapDownDetails details) { + _lastTapDownPosition = details.globalPosition; + } + + void _handleTapCancel() { + // longPress arrives after tapCancel, so remember the tap position. + _longPressPosition = _lastTapDownPosition; + _lastTapDownPosition = null; + } + + void _handleTap() { + assert(_lastTapDownPosition != null); + final globalPoint = _lastTapDownPosition; + _lastTapDownPosition = null; + HitTestResult result = new HitTestResult(); + WidgetsBinding.instance.hitTest(result, globalPoint); + + RenderEditableProxyBox box = _getEditableBox(result); + if (box == null) { + box = _editor.renderContext.closestBoxForGlobalPoint(globalPoint); + } + if (box == null) return null; + + final localPoint = box.globalToLocal(globalPoint); + final position = box.getPositionForOffset(localPoint); + final selection = new TextSelection.collapsed( + offset: position.offset, + affinity: position.affinity, + ); + if (_didCaretTap && _selection == selection) { + _didCaretTap = false; + if (isToolbarVisible) { + hideToolbar(); + } else { + showToolbar(); + } + } else { + _didCaretTap = true; + } + widget.controller.updateSelection(selection, source: ChangeSource.local); + } + + void _handleLongPress() { + final Offset globalPoint = _longPressPosition; + _longPressPosition = null; + HitTestResult result = new HitTestResult(); + WidgetsBinding.instance.hitTest(result, globalPoint); + final box = _getEditableBox(result); + if (box == null) { + return; + } + final localPoint = box.globalToLocal(globalPoint); + final position = box.getPositionForOffset(localPoint); + final word = box.getWordBoundary(position); + final selection = new TextSelection( + baseOffset: word.start, + extentOffset: word.end, + ); + widget.controller.updateSelection(selection, source: ChangeSource.local); + } + + @override + bool get copyEnabled => true; + + @override + bool get cutEnabled => true; + + @override + bool get pasteEnabled => true; + + @override + bool get selectAllEnabled => true; +} + +enum _SelectionHandlePosition { base, extent } + +class SelectionHandleDriver extends StatefulWidget { + const SelectionHandleDriver({ + Key key, + @required this.position, + @required this.controls, + }) : super(key: key); + + final _SelectionHandlePosition position; + final TextSelectionControls controls; + + @override + _SelectionHandleDriverState createState() => + new _SelectionHandleDriverState(); +} + +class _SelectionHandleDriverState extends State { + ZefyrScope _scope; + + /// Current document selection. + TextSelection get selection => _selection; + TextSelection _selection; + + /// Returns `true` if this handle is located at the baseOffset of selection. + bool get isBaseHandle => widget.position == _SelectionHandlePosition.base; + + /// Character offset of this handle in the document. + /// + /// For base handle this equals to [TextSelection.baseOffset] and for + /// extent handle - [TextSelection.extentOffset]. + int get documentOffset => + isBaseHandle ? selection.baseOffset : selection.extentOffset; + + /// Position in pixels of this selection handle within its paragraph [block]. + Offset getPosition(RenderEditableBox block) { + if (block == null) return null; + + final localSelection = block.getLocalSelection(selection); + assert(localSelection != null); + + final boxes = block.getEndpointsForSelection(selection); + assert(boxes.isNotEmpty, 'Got empty boxes for selection ${selection}'); + + final box = isBaseHandle ? boxes.first : boxes.last; + final dx = isBaseHandle ? box.start : box.end; + return new Offset(dx, box.bottom); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = ZefyrScope.of(context); + if (_scope != scope) { + _scope?.removeListener(_handleScopeChange); + _scope = scope; + _scope.addListener(_handleScopeChange); + } + _selection = _scope.selection; + } + + @override + void dispose() { + _scope?.removeListener(_handleScopeChange); + super.dispose(); + } + + // + // Overridden members + // + + @override + Widget build(BuildContext context) { + if (selection == null || + selection.isCollapsed || + widget.controls == null || + _scope.focusOwner != FocusOwner.editor) { + return new Container(); + } + final block = _scope.renderContext.boxForTextOffset(documentOffset); + final position = getPosition(block); + Widget handle; + if (position == null) { + handle = new Container(); + } else { + final handleType = isBaseHandle + ? TextSelectionHandleType.left + : TextSelectionHandleType.right; + handle = new Positioned( + left: position.dx, + top: position.dy, + child: widget.controls.buildHandle( + context, + handleType, + block.preferredLineHeight, + ), + ); + handle = new CompositedTransformFollower( + link: block.layerLink, + showWhenUnlinked: false, + child: new Stack( + overflow: Overflow.visible, + children: [handle], + ), + ); + } + // Always return this gesture detector even if handle is an empty container + // This way we prevent drag gesture from being canceled in case current + // position is somewhere outside of any visible paragraph block. + return new GestureDetector( + onPanStart: _handleDragStart, + onPanUpdate: _handleDragUpdate, + child: handle, + ); + } + + // + // Private members + // + + Offset _dragPosition; + + void _handleScopeChange() { + if (_selection != _scope.selection) { + setState(() { + _selection = _scope.selection; + }); + } + } + + void _handleDragStart(DragStartDetails details) { + _dragPosition = details.globalPosition; + } + + void _handleDragUpdate(DragUpdateDetails details) { + _dragPosition += details.delta; + final globalPoint = _dragPosition; + final paragraph = _scope.renderContext.boxForGlobalPoint(globalPoint); + if (paragraph == null) { + return; + } + + final localPoint = paragraph.globalToLocal(globalPoint); + final position = paragraph.getPositionForOffset(localPoint); + final newSelection = selection.copyWith( + baseOffset: isBaseHandle ? position.offset : selection.baseOffset, + extentOffset: isBaseHandle ? selection.extentOffset : position.offset, + ); + if (newSelection.baseOffset >= newSelection.extentOffset) { + // Don't allow reversed or collapsed selection. + return; + } + + if (newSelection != _selection) { + _scope.updateSelection(newSelection, source: ChangeSource.local); + } + } +} + +class _SelectionToolbar extends StatefulWidget { + const _SelectionToolbar({ + Key key, + @required this.scope, + @required this.controls, + @required this.delegate, + }) : super(key: key); + + final ZefyrScope scope; + final TextSelectionControls controls; + final TextSelectionDelegate delegate; + + @override + _SelectionToolbarState createState() => new _SelectionToolbarState(); +} + +class _SelectionToolbarState extends State<_SelectionToolbar> { + ZefyrScope get editable => widget.scope; + TextSelection get selection => widget.delegate.textEditingValue.selection; + + @override + Widget build(BuildContext context) { + return _buildToolbar(context); + } + + Widget _buildToolbar(BuildContext context) { + final base = selection.baseOffset; + // TODO: Editable is not refreshed and may contain stale renderContext instance. + final block = editable.renderContext.boxForTextOffset(base); + if (block == null) { + return Container(); + } + final boxes = block.getEndpointsForSelection(selection); + // Find the horizontal midpoint, just above the selected text. + final Offset midpoint = new Offset( + (boxes.length == 1) + ? (boxes[0].start + boxes[0].end) / 2.0 + : (boxes[0].start + boxes[1].start) / 2.0, + boxes[0].bottom - block.preferredLineHeight, + ); + + final Rect editingRegion = new Rect.fromPoints( + block.localToGlobal(Offset.zero), + block.localToGlobal(block.size.bottomRight(Offset.zero)), + ); +// final toolbar = widget.controls +// .buildToolbar(context, editingRegion, midpoint, widget.delegate); + final Offset endpoint = new Offset( + (boxes.length == 1) + ? (boxes[0].start + boxes[0].end) + : (boxes[0].start + boxes[1].start), + boxes[0].bottom - block.preferredLineHeight, + ); + final TextSelectionPoint textEndpoint = new TextSelectionPoint(endpoint, TextDirection.ltr); + final toolbar = widget.controls + .buildToolbar(context, editingRegion,0.0, midpoint, [textEndpoint], widget.delegate); + return new CompositedTransformFollower( + link: block.layerLink, + showWhenUnlinked: false, + offset: -editingRegion.topLeft, + child: toolbar, + ); + } +} diff --git a/zefyr/lib/src/widgets/theme.dart b/zefyr/lib/src/widgets/theme.dart new file mode 100644 index 00000000..0f7c1160 --- /dev/null +++ b/zefyr/lib/src/widgets/theme.dart @@ -0,0 +1,312 @@ +// 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:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// Applies a Zefyr editor theme to descendant widgets. +/// +/// Describes colors and typographic styles for an editor. +/// +/// Descendant widgets obtain the current theme's [ZefyrThemeData] object using +/// [ZefyrTheme.of]. +/// +/// See also: +/// +/// * [ZefyrThemeData], which describes actual configuration of a theme. +class ZefyrTheme extends InheritedWidget { + final ZefyrThemeData data; + + /// Applies the given theme [data] to [child]. + /// + /// The [data] and [child] arguments must not be null. + ZefyrTheme({ + Key key, + @required this.data, + @required Widget child, + }) : assert(data != null), + assert(child != null), + super(key: key, child: child); + + @override + bool updateShouldNotify(ZefyrTheme oldWidget) { + return data != oldWidget.data; + } + + /// The data from the closest [ZefyrTheme] instance that encloses the given + /// context. + /// + /// Returns `null` if there is no [ZefyrTheme] in the given build context + /// and [nullOk] is set to `true`. If [nullOk] is set to `false` (default) + /// then this method asserts. + static ZefyrThemeData of(BuildContext context, {bool nullOk: false}) { + final ZefyrTheme widget = context.inheritFromWidgetOfExactType(ZefyrTheme); + if (widget == null && nullOk) return null; + assert(widget != null, + '$ZefyrTheme.of() called with a context that does not contain a ZefyrEditor.'); + return widget.data; + } +} + +/// Holds colors and typography styles for [ZefyrEditor]. +class ZefyrThemeData { + final TextStyle boldStyle; + final TextStyle italicStyle; + final TextStyle linkStyle; + final StyleTheme paragraphTheme; + final HeadingTheme headingTheme; + final BlockTheme blockTheme; + final Color selectionColor; + final Color cursorColor; + + /// Size of indentation for blocks. + final double indentSize; + final ZefyrToolbarTheme toolbarTheme; + + factory ZefyrThemeData.fallback(BuildContext context) { + final defaultStyle = DefaultTextStyle.of(context); + final paragraphStyle = defaultStyle.style.copyWith( + fontSize: 16.0, + height: 1.25, + fontWeight: FontWeight.normal, + color: Colors.grey.shade800, + ); + final padding = const EdgeInsets.only(bottom: 16.0); + final boldStyle = new TextStyle(fontWeight: FontWeight.bold); + final italicStyle = new TextStyle(fontStyle: FontStyle.italic); + final linkStyle = + TextStyle(color: Colors.blue, decoration: TextDecoration.underline); + + return new ZefyrThemeData( + boldStyle: boldStyle, + italicStyle: italicStyle, + linkStyle: linkStyle, + paragraphTheme: + new StyleTheme(textStyle: paragraphStyle, padding: padding), + headingTheme: new HeadingTheme.fallback(), + blockTheme: new BlockTheme.fallback(), + selectionColor: Colors.lightBlueAccent.shade100, + cursorColor: Colors.black, + indentSize: 16.0, + toolbarTheme: new ZefyrToolbarTheme.fallback(context), + ); + } + + const ZefyrThemeData({ + this.boldStyle, + this.italicStyle, + this.linkStyle, + this.paragraphTheme, + this.headingTheme, + this.blockTheme, + this.selectionColor, + this.cursorColor, + this.indentSize, + this.toolbarTheme, + }); + + ZefyrThemeData copyWith({ + TextStyle textStyle, + TextStyle boldStyle, + TextStyle italicStyle, + TextStyle linkStyle, + StyleTheme paragraphTheme, + HeadingTheme headingTheme, + BlockTheme blockTheme, + Color selectionColor, + Color cursorColor, + double indentSize, + ZefyrToolbarTheme toolbarTheme, + }) { + return new ZefyrThemeData( + boldStyle: boldStyle ?? this.boldStyle, + italicStyle: italicStyle ?? this.italicStyle, + linkStyle: linkStyle ?? this.linkStyle, + paragraphTheme: paragraphTheme ?? this.paragraphTheme, + headingTheme: headingTheme ?? this.headingTheme, + blockTheme: blockTheme ?? this.blockTheme, + selectionColor: selectionColor ?? this.selectionColor, + cursorColor: cursorColor ?? this.cursorColor, + indentSize: indentSize ?? this.indentSize, + toolbarTheme: toolbarTheme ?? this.toolbarTheme, + ); + } + + ZefyrThemeData merge(ZefyrThemeData other) { + return copyWith( + boldStyle: other.boldStyle, + italicStyle: other.italicStyle, + linkStyle: other.linkStyle, + paragraphTheme: other.paragraphTheme, + headingTheme: other.headingTheme, + blockTheme: other.blockTheme, + selectionColor: other.selectionColor, + cursorColor: other.cursorColor, + indentSize: other.indentSize, + toolbarTheme: other.toolbarTheme, + ); + } +} + +/// Theme for heading-styled lines of text. +class HeadingTheme { + /// Style theme for level 1 headings. + final StyleTheme level1; + + /// Style theme for level 2 headings. + final StyleTheme level2; + + /// Style theme for level 3 headings. + final StyleTheme level3; + + HeadingTheme({ + @required this.level1, + @required this.level2, + @required this.level3, + }); + + /// Creates fallback theme for headings. + factory HeadingTheme.fallback() { + return HeadingTheme( + level1: StyleTheme( + textStyle: TextStyle( + fontSize: 30.0, + color: Colors.grey.shade800, + height: 1.25, + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.only(top: 16.0, bottom: 16.0), + ), + level2: StyleTheme( + textStyle: TextStyle( + fontSize: 24.0, + color: Colors.grey.shade800, + height: 1.25, + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.only(bottom: 8.0, top: 8.0), + ), + level3: StyleTheme( + textStyle: TextStyle( + fontSize: 20.0, + color: Colors.grey.shade800, + height: 1.25, + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.only(bottom: 8.0, top: 8.0), + ), + ); + } +} + +/// Theme for a block of lines in a document. +class BlockTheme { + /// Style theme for bullet lists. + final StyleTheme bulletList; + + /// Style theme for number lists. + final StyleTheme numberList; + + /// Style theme for code snippets. + final StyleTheme code; + + /// Style theme for quotes. + final StyleTheme quote; + + BlockTheme({ + @required this.bulletList, + @required this.numberList, + @required this.quote, + @required this.code, + }); + + /// Creates fallback theme for blocks. + factory BlockTheme.fallback() { + final padding = const EdgeInsets.only(bottom: 8.0); + return new BlockTheme( + bulletList: new StyleTheme(padding: padding), + numberList: new StyleTheme(padding: padding), + quote: new StyleTheme( + textStyle: new TextStyle(color: Colors.grey.shade700), + padding: padding, + ), + code: new StyleTheme( + textStyle: new TextStyle( + color: Colors.blueGrey.shade800, + fontFamily: Platform.isIOS ? 'Menlo' : 'Roboto Mono', + fontSize: 14.0, + height: 1.25, + ), + padding: padding, + ), + ); + } +} + +/// Theme for a specific attribute style. +/// +/// Used in [HeadingTheme] and [BlockTheme], as well as in +/// [ZefyrThemeData.paragraphTheme]. +class StyleTheme { + /// Text style of this theme. + final TextStyle textStyle; + + /// Padding to apply around lines of text. + final EdgeInsets padding; + + /// Creates a new [StyleTheme]. + StyleTheme({ + this.textStyle, + this.padding, + }); +} + +/// Defines styles and colors for [ZefyrToolbar]. +class ZefyrToolbarTheme { + /// The background color of toolbar. + final Color color; + + /// Color of buttons in toggled state. + final Color toggleColor; + + /// Color of button icons. + final Color iconColor; + + /// Color of button icons in disabled state. + final Color disabledIconColor; + + /// Creates fallback theme for editor toolbars. + factory ZefyrToolbarTheme.fallback(BuildContext context) { + final theme = Theme.of(context); + return ZefyrToolbarTheme._( + color: theme.primaryColorLight, + toggleColor: theme.primaryColor, + iconColor: theme.primaryIconTheme.color, + disabledIconColor: theme.primaryColor, + ); + } + + ZefyrToolbarTheme._({ + @required this.color, + @required this.toggleColor, + @required this.iconColor, + @required this.disabledIconColor, + }); + + ZefyrToolbarTheme copyWith({ + Color color, + Color toggleColor, + Color iconColor, + Color disabledIconColor, + }) { + return ZefyrToolbarTheme._( + color: color ?? this.color, + toggleColor: toggleColor ?? this.toggleColor, + iconColor: iconColor ?? this.iconColor, + disabledIconColor: disabledIconColor ?? this.disabledIconColor, + ); + } +} diff --git a/zefyr/lib/src/widgets/toolbar.dart b/zefyr/lib/src/widgets/toolbar.dart new file mode 100644 index 00000000..d9364cbf --- /dev/null +++ b/zefyr/lib/src/widgets/toolbar.dart @@ -0,0 +1,398 @@ +// 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:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:notus/notus.dart'; + +import 'buttons.dart'; +import 'scope.dart'; +import 'theme.dart'; + +/// List of all button actions supported by [ZefyrToolbar] buttons. +enum ZefyrToolbarAction { + bold, + italic, + link, + unlink, + clipboardCopy, + openInBrowser, + heading, + headingLevel1, + headingLevel2, + headingLevel3, + bulletList, + numberList, + code, + quote, + horizontalRule, + image, + cameraImage, + galleryImage, + hideKeyboard, + close, + confirm, +} + +final kZefyrToolbarAttributeActions = { + ZefyrToolbarAction.bold: NotusAttribute.bold, + ZefyrToolbarAction.italic: NotusAttribute.italic, + ZefyrToolbarAction.link: NotusAttribute.link, + ZefyrToolbarAction.heading: NotusAttribute.heading, + ZefyrToolbarAction.headingLevel1: NotusAttribute.heading.level1, + ZefyrToolbarAction.headingLevel2: NotusAttribute.heading.level2, + ZefyrToolbarAction.headingLevel3: NotusAttribute.heading.level3, + ZefyrToolbarAction.bulletList: NotusAttribute.block.bulletList, + ZefyrToolbarAction.numberList: NotusAttribute.block.numberList, + ZefyrToolbarAction.code: NotusAttribute.block.code, + ZefyrToolbarAction.quote: NotusAttribute.block.quote, + ZefyrToolbarAction.horizontalRule: NotusAttribute.embed.horizontalRule, +}; + +/// Allows customizing appearance of [ZefyrToolbar]. +abstract class ZefyrToolbarDelegate { + /// Builds toolbar button for specified [action]. + /// + /// Returned widget is usually an instance of [ZefyrButton]. + Widget buildButton(BuildContext context, ZefyrToolbarAction action, + {VoidCallback onPressed}); +} + +/// Scaffold for [ZefyrToolbar]. +class ZefyrToolbarScaffold extends StatelessWidget { + const ZefyrToolbarScaffold({ + Key key, + @required this.body, + this.trailing, + this.autoImplyTrailing: true, + }) : super(key: key); + + final Widget body; + final Widget trailing; + final bool autoImplyTrailing; + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context).toolbarTheme; + final toolbar = ZefyrToolbar.of(context); + final constraints = + BoxConstraints.tightFor(height: ZefyrToolbar.kToolbarHeight); + final children = [ + Expanded(child: body), + ]; + + if (trailing != null) { + children.add(trailing); + } else if (autoImplyTrailing) { + children.add(toolbar.buildButton(context, ZefyrToolbarAction.close)); + } + return new Container( + constraints: constraints, + child: Material(color: theme.color, child: Row(children: children)), + ); + } +} + +/// Toolbar for [ZefyrEditor]. +class ZefyrToolbar extends StatefulWidget implements PreferredSizeWidget { + static const kToolbarHeight = 50.0; + + const ZefyrToolbar({ + Key key, + @required this.editor, + this.autoHide: true, + this.delegate, + }) : super(key: key); + + final ZefyrToolbarDelegate delegate; + final ZefyrScope editor; + + /// Whether to automatically hide this toolbar when editor loses focus. + final bool autoHide; + + static ZefyrToolbarState of(BuildContext context) { + final _ZefyrToolbarScope scope = + context.inheritFromWidgetOfExactType(_ZefyrToolbarScope); + return scope?.toolbar; + } + + @override + ZefyrToolbarState createState() => ZefyrToolbarState(); + + @override + ui.Size get preferredSize => new Size.fromHeight(ZefyrToolbar.kToolbarHeight); +} + +class _ZefyrToolbarScope extends InheritedWidget { + _ZefyrToolbarScope({Key key, @required Widget child, @required this.toolbar}) + : super(key: key, child: child); + + final ZefyrToolbarState toolbar; + + @override + bool updateShouldNotify(_ZefyrToolbarScope oldWidget) { + return toolbar != oldWidget.toolbar; + } +} + +class ZefyrToolbarState extends State + with SingleTickerProviderStateMixin { + final Key _toolbarKey = UniqueKey(); + final Key _overlayKey = UniqueKey(); + + ZefyrToolbarDelegate _delegate; + AnimationController _overlayAnimation; + WidgetBuilder _overlayBuilder; + Completer _overlayCompleter; + + TextSelection _selection; + + void markNeedsRebuild() { + setState(() { + if (_selection != editor.selection) { + _selection = editor.selection; + closeOverlay(); + } + }); + } + + Widget buildButton(BuildContext context, ZefyrToolbarAction action, + {VoidCallback onPressed}) { + return _delegate.buildButton(context, action, onPressed: onPressed); + } + + Future showOverlay(WidgetBuilder builder) async { + assert(_overlayBuilder == null); + final completer = new Completer(); + setState(() { + _overlayBuilder = builder; + _overlayCompleter = completer; + _overlayAnimation.forward(); + }); + return completer.future; + } + + void closeOverlay() { + if (!hasOverlay) return; + _overlayAnimation.reverse().whenComplete(() { + setState(() { + _overlayBuilder = null; + _overlayCompleter?.complete(); + _overlayCompleter = null; + }); + }); + } + + bool get hasOverlay => _overlayBuilder != null; + + ZefyrScope get editor => widget.editor; + + @override + void initState() { + super.initState(); + _delegate = widget.delegate ?? new _DefaultZefyrToolbarDelegate(); + _overlayAnimation = new AnimationController( + vsync: this, duration: Duration(milliseconds: 100)); + _selection = editor.selection; + } + + @override + void didUpdateWidget(ZefyrToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.delegate != oldWidget.delegate) { + _delegate = widget.delegate ?? new _DefaultZefyrToolbarDelegate(); + } + } + + @override + void dispose() { + _overlayAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final layers = []; + + // Must set unique key for the toolbar to prevent it from reconstructing + // new state each time we toggle overlay. + final toolbar = ZefyrToolbarScaffold( + key: _toolbarKey, + body: ZefyrButtonList(buttons: _buildButtons(context)), + trailing: buildButton(context, ZefyrToolbarAction.hideKeyboard), + ); + + layers.add(toolbar); + + if (hasOverlay) { + Widget widget = new Builder(builder: _overlayBuilder); + assert(widget != null); + final overlay = FadeTransition( + key: _overlayKey, + opacity: _overlayAnimation, + child: widget, + ); + layers.add(overlay); + } + + final constraints = + BoxConstraints.tightFor(height: ZefyrToolbar.kToolbarHeight); + return _ZefyrToolbarScope( + toolbar: this, + child: Container( + constraints: constraints, + child: Stack(children: layers), + ), + ); + } + + List _buildButtons(BuildContext context) { + final buttons = [ + buildButton(context, ZefyrToolbarAction.bold), + buildButton(context, ZefyrToolbarAction.italic), + LinkButton(), + HeadingButton(), + buildButton(context, ZefyrToolbarAction.bulletList), + buildButton(context, ZefyrToolbarAction.numberList), + buildButton(context, ZefyrToolbarAction.quote), + buildButton(context, ZefyrToolbarAction.code), + buildButton(context, ZefyrToolbarAction.horizontalRule), + ImageButton(), + ]; + return buttons; + } +} + +/// Scrollable list of toolbar buttons. +class ZefyrButtonList extends StatefulWidget { + const ZefyrButtonList({Key key, @required this.buttons}) : super(key: key); + final List buttons; + + @override + _ZefyrButtonListState createState() => _ZefyrButtonListState(); +} + +class _ZefyrButtonListState extends State { + final ScrollController _controller = new ScrollController(); + bool _showLeftArrow = false; + bool _showRightArrow = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_handleScroll); + // Workaround to allow scroll controller attach to our ListView so that + // we can detect if overflow arrows need to be shown on init. + // TODO: find a better way to detect overflow + Timer.run(_handleScroll); + } + + @override + Widget build(BuildContext context) { + final theme = ZefyrTheme.of(context).toolbarTheme; + final color = theme.iconColor; + final list = ListView( + scrollDirection: Axis.horizontal, + controller: _controller, + children: widget.buttons, + physics: ClampingScrollPhysics(), + ); + + final leftArrow = _showLeftArrow + ? Icon(Icons.arrow_left, size: 18.0, color: color) + : null; + final rightArrow = _showRightArrow + ? Icon(Icons.arrow_right, size: 18.0, color: color) + : null; + return Row( + children: [ + SizedBox( + width: 12.0, + height: ZefyrToolbar.kToolbarHeight, + child: Container(child: leftArrow, color: theme.color), + ), + Expanded(child: ClipRect(child: list)), + SizedBox( + width: 12.0, + height: ZefyrToolbar.kToolbarHeight, + child: Container(child: rightArrow, color: theme.color), + ), + ], + ); + } + + void _handleScroll() { + setState(() { + _showLeftArrow = + _controller.position.minScrollExtent != _controller.position.pixels; + _showRightArrow = + _controller.position.maxScrollExtent != _controller.position.pixels; + }); + } +} + +class _DefaultZefyrToolbarDelegate implements ZefyrToolbarDelegate { + static const kDefaultButtonIcons = { + ZefyrToolbarAction.bold: Icons.format_bold, + ZefyrToolbarAction.italic: Icons.format_italic, + ZefyrToolbarAction.link: Icons.link, + ZefyrToolbarAction.unlink: Icons.link_off, + ZefyrToolbarAction.clipboardCopy: Icons.content_copy, + ZefyrToolbarAction.openInBrowser: Icons.open_in_new, + ZefyrToolbarAction.heading: Icons.format_size, + ZefyrToolbarAction.bulletList: Icons.format_list_bulleted, + ZefyrToolbarAction.numberList: Icons.format_list_numbered, + ZefyrToolbarAction.code: Icons.code, + ZefyrToolbarAction.quote: Icons.format_quote, + ZefyrToolbarAction.horizontalRule: Icons.remove, + ZefyrToolbarAction.image: Icons.photo, + ZefyrToolbarAction.cameraImage: Icons.photo_camera, + ZefyrToolbarAction.galleryImage: Icons.photo_library, + ZefyrToolbarAction.hideKeyboard: Icons.keyboard_hide, + ZefyrToolbarAction.close: Icons.close, + ZefyrToolbarAction.confirm: Icons.check, + }; + + static const kSpecialIconSizes = { + ZefyrToolbarAction.unlink: 20.0, + ZefyrToolbarAction.clipboardCopy: 20.0, + ZefyrToolbarAction.openInBrowser: 20.0, + ZefyrToolbarAction.close: 20.0, + ZefyrToolbarAction.confirm: 20.0, + }; + + static const kDefaultButtonTexts = { + ZefyrToolbarAction.headingLevel1: 'H1', + ZefyrToolbarAction.headingLevel2: 'H2', + ZefyrToolbarAction.headingLevel3: 'H3', + }; + + @override + Widget buildButton(BuildContext context, ZefyrToolbarAction action, + {VoidCallback onPressed}) { + final theme = Theme.of(context); + if (kDefaultButtonIcons.containsKey(action)) { + final icon = kDefaultButtonIcons[action]; + final size = kSpecialIconSizes[action]; + return ZefyrButton.icon( + action: action, + icon: icon, + iconSize: size, + onPressed: onPressed, + ); + } else { + final text = kDefaultButtonTexts[action]; + assert(text != null); + final style = theme.textTheme.caption + .copyWith(fontWeight: FontWeight.bold, fontSize: 14.0); + return ZefyrButton.text( + action: action, + text: text, + style: style, + onPressed: onPressed, + ); + } + } +} diff --git a/zefyr/lib/src/widgets/view.dart b/zefyr/lib/src/widgets/view.dart new file mode 100644 index 00000000..d7b7b13e --- /dev/null +++ b/zefyr/lib/src/widgets/view.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import 'package:notus/notus.dart'; + +import 'code.dart'; +import 'common.dart'; +import 'image.dart'; +import 'list.dart'; +import 'paragraph.dart'; +import 'quote.dart'; +import 'scope.dart'; +import 'theme.dart'; + +/// Non-scrollable read-only view of Notus rich text documents. +@experimental +class ZefyrView extends StatefulWidget { + final NotusDocument document; + final ZefyrImageDelegate imageDelegate; + + const ZefyrView({Key key, @required this.document, this.imageDelegate}) + : super(key: key); + + @override + ZefyrViewState createState() => ZefyrViewState(); +} + +class ZefyrViewState extends State { + ZefyrScope _scope; + ZefyrThemeData _themeData; + + ZefyrImageDelegate get imageDelegate => widget.imageDelegate; + + @override + void initState() { + super.initState(); + _scope = ZefyrScope.view(imageDelegate: widget.imageDelegate); + } + + @override + void didUpdateWidget(ZefyrView oldWidget) { + super.didUpdateWidget(oldWidget); + _scope.imageDelegate = widget.imageDelegate; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parentTheme = ZefyrTheme.of(context, nullOk: true); + final fallbackTheme = ZefyrThemeData.fallback(context); + _themeData = (parentTheme != null) + ? fallbackTheme.merge(parentTheme) + : fallbackTheme; + } + + @override + void dispose() { + _scope.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ZefyrTheme( + data: _themeData, + child: ZefyrScopeAccess( + scope: _scope, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildChildren(context), + ), + ), + ); + } + + List _buildChildren(BuildContext context) { + final result = []; + for (var node in widget.document.root.children) { + result.add(_defaultChildBuilder(context, node)); + } + return result; + } + + Widget _defaultChildBuilder(BuildContext context, Node node) { + if (node is LineNode) { + if (node.hasEmbed) { + return new RawZefyrLine(node: node); + } else if (node.style.contains(NotusAttribute.heading)) { + return new ZefyrHeading(node: node); + } + return new ZefyrParagraph(node: node); + } + + final BlockNode block = node; + final blockStyle = block.style.get(NotusAttribute.block); + if (blockStyle == NotusAttribute.block.code) { + return new ZefyrCode(node: block); + } else if (blockStyle == NotusAttribute.block.bulletList) { + return new ZefyrList(node: block); + } else if (blockStyle == NotusAttribute.block.numberList) { + return new ZefyrList(node: block); + } else if (blockStyle == NotusAttribute.block.quote) { + return new ZefyrQuote(node: block); + } + + throw new UnimplementedError('Block format $blockStyle.'); + } +} diff --git a/zefyr/lib/util.dart b/zefyr/lib/util.dart new file mode 100644 index 00000000..7ef640e8 --- /dev/null +++ b/zefyr/lib/util.dart @@ -0,0 +1,40 @@ +// 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. + +/// Utility functions for Zefyr. +library zefyr.util; + +import 'dart:math' as math; + +import 'package:quill_delta/quill_delta.dart'; + +export 'src/fast_diff.dart'; + +int getPositionDelta(Delta user, Delta actual) { + final userIter = new DeltaIterator(user); + final actualIter = new DeltaIterator(actual); + int diff = 0; + while (userIter.hasNext || actualIter.hasNext) { + num length = math.min(userIter.peekLength(), actualIter.peekLength()); + final userOp = userIter.next(length); + final actualOp = actualIter.next(length); + assert(userOp.length == actualOp.length); + if (userOp.key == actualOp.key) continue; + if (userOp.isInsert && actualOp.isRetain) { + diff -= userOp.length; + } 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. + } + } + return diff; +} diff --git a/zefyr/lib/zefyr.dart b/zefyr/lib/zefyr.dart new file mode 100644 index 00000000..846ba5f5 --- /dev/null +++ b/zefyr/lib/zefyr.dart @@ -0,0 +1,22 @@ +library zefyr; + +export 'package:notus/notus.dart'; + +export 'src/widgets/buttons.dart' hide HeadingButton, LinkButton; +export 'src/widgets/code.dart'; +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/scope.dart' hide ZefyrScopeAccess; +export 'src/widgets/selection.dart' hide SelectionHandleDriver; +export 'src/widgets/theme.dart'; +export 'src/widgets/toolbar.dart'; +export 'src/widgets/view.dart'; \ No newline at end of file diff --git a/zefyr/pubspec.yaml b/zefyr/pubspec.yaml new file mode 100644 index 00000000..24625589 --- /dev/null +++ b/zefyr/pubspec.yaml @@ -0,0 +1,59 @@ +name: zefyr +description: A new Flutter package project. +version: 0.0.1 +author: Anatoly Pulyaevskiy +homepage: + +environment: + sdk: ">=2.1.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + collection: ^1.14.6 + url_launcher: ^5.0.0 + image_picker: ^0.5.0 + quill_delta: ^1.0.0-dev.1.0 + notus: ^0.1.0 + meta: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/zefyr/test/zefyr_test.dart b/zefyr/test/zefyr_test.dart new file mode 100644 index 00000000..f9502254 --- /dev/null +++ b/zefyr/test/zefyr_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:zefyr/zefyr.dart'; + +void main() { +// test('adds one to input values', () { +// final calculator = Calculator(); +// expect(calculator.addOne(2), 3); +// expect(calculator.addOne(-7), -6); +// expect(calculator.addOne(0), 1); +// expect(() => calculator.addOne(null), throwsNoSuchMethodError); +// }); +}