This commit is contained in:
sanfan.hx
2019-08-07 18:22:52 +08:00
47 changed files with 5271 additions and 544 deletions

View File

@ -23,11 +23,14 @@ class Api{
static const String ADD_COLLECTION = BASE_URL+'auth/addCollection';//添加收藏
static const String CHECK_COLLECTED = BASE_URL+'auth/checkCollected';//校验收藏
static const String CHECK_COLLECTED = BASE_URL+'checkCollected';//校验收藏
static const String SET_THEMECOLOR = BASE_URL+'auth/setThemeColor';//设置主题颜色
static const String GET_THEMECOLOR = BASE_URL +'/getThemeColor';//获取主题颜色
static const String GET_WIDGET_TREE = 'http://flutter-go.alibaba.net/' + 'getCateList';//获取widget列表树
static const String SEARCH_WIDGET = BASE_URL+'searchWidget';//搜索组件
}

View File

@ -4,6 +4,7 @@
import 'dart:core';
import 'package:flutter/material.dart';
import 'package:flutter_go/utils/data_utils.dart';
import '../routers/application.dart';
import '../routers/routers.dart';
@ -37,7 +38,7 @@ class _WidgetDemoState extends State<WidgetDemo> {
CollectionControlModel _collectionControl = new CollectionControlModel();
var _collectionIcons;
List widgetDemosList = new WidgetDemoList().getDemos();
String _router = '';
String widgetType = 'old';
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
List<Widget> _buildContent() {
@ -65,60 +66,66 @@ class _WidgetDemoState extends State<WidgetDemo> {
void initState() {
super.initState();
// 这里不能直接 使用 ` ModalRoute.of(context)` 会产生报错
Future.delayed(Duration.zero,() {
Future.delayed(Duration.zero, () {
String currentPath = ModalRoute.of(context).settings.name;
_collectionControl.getRouterByUrl(currentPath).then((list) {
if (currentPath.indexOf('/standard-page') == 0) {
widgetType = 'standard';
}
Map<String, String> params = {
'type': widgetType,
"url": currentPath,
"name": widget.title
};
DataUtils.checkCollected(params).then((result) {
if (this.mounted) {
setState(() {
_hasCollected = list.length > 0;
_hasCollected = result;
});
}
});
});
}
// 点击收藏按钮
_getCollection() {
String currentRouterPath = ModalRoute.of(context).settings.name;
Map<String, String> params = {
"type": widgetType,
"url": currentRouterPath,
"name": widget.title
};
if (_hasCollected) {
// 删除操作
_collectionControl.deleteByPath(currentRouterPath).then((result) {
if (result > 0 && this.mounted) {
setState(() {
_hasCollected = false;
});
DataUtils.removeCollected(params, context).then((result) {
if (result) {
_scaffoldKey.currentState
.showSnackBar(SnackBar(content: Text('已取消收藏')));
if (ApplicationEvent.event != null) {
ApplicationEvent.event
.fire(CollectionEvent(widget.title, currentRouterPath, true));
}
return;
if (this.mounted) {
setState(() {
_hasCollected = false;
});
}
}
print('删除错误');
});
} else {
// 插入操作
_collectionControl
.insert(Collection(name: widget.title, router: currentRouterPath))
.then((result) {
if (this.mounted) {
setState(() {
_hasCollected = true;
});
DataUtils.addCollected(params, context).then((result) {
if (result) {
if (this.mounted) {
setState(() {
_hasCollected = true;
});
}
_scaffoldKey.currentState
.showSnackBar(SnackBar(content: Text('收藏成功')));
if (ApplicationEvent.event != null) {
ApplicationEvent.event
.fire(CollectionEvent(widget.title, currentRouterPath, false));
}
_scaffoldKey.currentState
.showSnackBar(SnackBar(content: Text('收藏成功')));
}
});
}

View File

@ -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<String, List<String>> params) {
return IssuesMessagePage();
});

View File

@ -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<String, List<String>> params) {

View File

@ -1,7 +1,9 @@
import 'dart:async' show Future;
import 'package:fluro/fluro.dart';
import 'package:flutter_go/model/collection.dart';
import 'package:flutter_go/model/version.dart';
import 'package:flutter_go/model/widget.dart';
import 'package:package_info/package_info.dart';
import 'package:flutter_go/model/responseData.dart';
@ -39,7 +41,6 @@ class DataUtils {
var response = await NetUtils.get(Api.CHECK_LOGIN);
print('response: $response');
try {
print('1111');
if (response['success']) {
print('${response['success']} ${response['data']} response[succes]');
UserInformation userInfo = UserInformation.fromJson(response['data']);
@ -56,12 +57,11 @@ class DataUtils {
// 一键反馈
static Future feedback(Map<String, String> 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'];
}
//设置主题颜色
@ -104,15 +104,97 @@ class DataUtils {
return false;
}
}
/// 获取widget列表处的树型数据
static Future<List> getWidgetTreeList() async {
var response = await NetUtils.get(Api.GET_WIDGET_TREE);
try {
var response = await NetUtils.get(Api.GET_WIDGET_TREE);
print('组件树:$response');
if (response != null && response['success']) {
return response['data'];
} else {
return [];
}
} catch (error) {
print('获取组件树 error $error');
}
}
if (response['success']) {
return response['data'];
// 校验是否收藏
static Future<bool> checkCollected(Map<String, String> params) async {
print('url 地址:${Api.CHECK_COLLECTED} $params');
try {
var response = await NetUtils.post(Api.CHECK_COLLECTED, params);
return response != null && response['hasCollected'];
} catch (error) {
print('校验收藏 error $error');
}
}
// 添加收藏
static Future addCollected(Map<String, String> params, context) async {
var response = await NetUtils.post(Api.ADD_COLLECTION, params);
if (response['status'] == 401 && response['message'] == '请先登录') {
Application.router.navigateTo(context, '${Routes.loginPage}',
transition: TransitionType.nativeModal);
}
return response != null && response['success'];
}
// 移出收藏
static Future removeCollected(Map<String, String> params, context) async {
var response = await NetUtils.post(Api.REMOVE_COLLECTION, params);
if (response['status'] == 401 && response['message'] == '请先登录') {
Application.router.navigateTo(context, '${Routes.loginPage}',
transition: TransitionType.nativeModal);
}
return response != null && response['success'];
}
// 获取全部收藏
static Future getAllCollections(context) async {
var response = await NetUtils.get(Api.GET_ALL_COLLECTION);
List<Collection> responseList = [];
if (response['status'] == 401 && response['message'] == '请先登录') {
Application.router.navigateTo(context, '${Routes.loginPage}',
transition: TransitionType.nativeModal);
}
if (response != null && response['success'] == true) {
for (int i = 0; i < response['data'].length; i++) {
Map<String, dynamic> tempCo = response['data'][i];
responseList.add(Collection.fromJSON(
{"name": tempCo['name'], "router": tempCo['url']}));
}
return responseList;
} else {
return [];
}
}
// 搜索组件
static Future searchWidget(String name) async {
var response = await NetUtils.get(Api.SEARCH_WIDGET, {"name": name});
List<WidgetPoint> list = [];
if (response != null && response['success'] == true) {
for (int i = 0; i < response['data'].length; i++) {
var json = response['data'][i];
String routerName;
if (json['display'] == 'old') {
routerName = json['path'];
} else {
routerName = json['pageId'];
}
Map<String, dynamic> tempMap = {
"name": json['name'],
"cnName": json['name'],
"routerName": routerName,
"catId": int.parse(json['parentId'])
};
list.add(WidgetPoint.fromJSON(tempMap));
}
return list;
} else {
return [];
}
}
}

View File

@ -4,12 +4,13 @@
/// @Last Modified time: 2019-06-05 14:01:03
import 'package:flutter/material.dart';
import 'package:event_bus/event_bus.dart';
import 'package:fluro/fluro.dart';
import 'package:flutter_go/model/collection.dart';
import 'package:flutter_go/routers/application.dart';
import 'package:flutter_go/routers/routers.dart';
import 'package:flutter_go/event/event_bus.dart';
import 'package:flutter_go/event/event_model.dart';
import 'package:flutter_go/utils/data_utils.dart';
class CollectionFullPage extends StatefulWidget {
final bool hasLogined;
@ -41,13 +42,10 @@ class _CollectionFullPageState extends State<CollectionFullPage> {
void _getList() {
_collectionList.clear();
_collectionControl.getAllCollection().then((resultList) {
resultList.forEach((item) {
_collectionList.add(item);
});
DataUtils.getAllCollections(context).then((collectionList) {
if (this.mounted) {
setState(() {
_collectionList = _collectionList;
_collectionList = collectionList;
});
}
});
@ -73,7 +71,7 @@ class _CollectionFullPageState extends State<CollectionFullPage> {
SizedBox(
width: 5.0,
),
Text('模拟器重新运行会丢失收藏'),
Text('常用的组件都可以收藏在这里哦'),
],
),
);
@ -115,21 +113,24 @@ class _CollectionFullPageState extends State<CollectionFullPage> {
trailing:
Icon(Icons.keyboard_arrow_right, color: Colors.grey, size: 30.0),
onTap: () {
if (_collectionList[index - 1].router.contains('http')) {
// 注意这里title已经转义过了
Application.router.navigateTo(context,
'${Routes.webViewPage}?title=${_collectionList[index - 1].name}&url=${Uri.encodeComponent(_collectionList[index - 1].router)}');
} else {
Application.router
.navigateTo(context, "${_collectionList[index - 1].router}");
}
Application.router.navigateTo(
context, _collectionList[index - 1].router,
transition: TransitionType.inFromRight);
// if (_collectionList[index - 1].router.contains('http')) {
// // 注意这里title已经转义过了
// Application.router.navigateTo(context,
// '${Routes.webViewPage}?title=${_collectionList[index - 1].name}&url=${Uri.encodeComponent(_collectionList[index - 1].router)}');
// } else {
// Application.router
// .navigateTo(context, "${_collectionList[index - 1].router}");
// }
},
),
);
}
ListView buildContent(){
ListView buildContent() {
if (_collectionList.length == 0) {
return ListView(
children: <Widget>[

View File

@ -11,6 +11,7 @@ import 'package:flutter_go/routers/application.dart';
import 'package:flutter_go/routers/routers.dart';
import 'package:flutter_go/event/event_bus.dart';
import 'package:flutter_go/event/event_model.dart';
import 'package:flutter_go/utils/data_utils.dart';
class CollectionPage extends StatefulWidget {
final bool hasLogined;
@ -47,14 +48,10 @@ class _CollectionPageState extends State<CollectionPage> {
void _getList() {
_collectionList.clear();
_collectionControl.getAllCollection().then((resultList) {
resultList.forEach((item) {
_collectionList.add(item);
});
print("_collectionList ${_collectionList}");
DataUtils.getAllCollections(context).then((collectionList) {
if (this.mounted) {
setState(() {
_collectionList = _collectionList;
_collectionList = collectionList;
});
}
});

View File

@ -205,28 +205,14 @@ class _DrawerPageState extends State<DrawerPage> {
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,

View File

@ -7,9 +7,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_go/utils/data_utils.dart';
import 'package:flutter_go/utils/shared_preferences.dart';
import 'package:flutter_go/views/first_page/first_page.dart';
import 'package:flutter_go/views/first_page/main_page.dart';
import 'package:fluro/fluro.dart';
import 'package:flutter_go/views/widget_page/widget_page.dart';
import 'package:flutter_go/views/welcome_page/fourth_page.dart';
import 'package:flutter_go/views/collection_page/collection_page.dart';
@ -87,26 +89,21 @@ class _MyHomePageState extends State<AppPage>
}
void onWidgetTap(WidgetPoint widgetPoint, BuildContext context) {
List widgetDemosList = new WidgetDemoList().getDemos();
String targetName = widgetPoint.name;
String targetRouter = '/category/error/404';
widgetDemosList.forEach((item) {
if (item.name == targetName) {
targetRouter = item.routerName;
}
});
searchHistoryList
.add(SearchHistory(name: targetName, targetRouter: targetRouter));
.add(SearchHistory(name: targetName, targetRouter: widgetPoint.routerName));
print("searchHistoryList1 ${searchHistoryList.toString()}");
print("searchHistoryList2 ${targetRouter}");
print("searchHistoryList3 ${widgetPoint.name}");
Application.router.navigateTo(context, "$targetRouter");
Application.router.navigateTo(
context, widgetPoint.routerName,
transition: TransitionType.inFromRight);
}
Widget buildSearchInput(BuildContext context) {
return new SearchInput((value) async {
if (value != '') {
List<WidgetPoint> list = await widgetControl.search(value);
print('value ::: $value');
// List<WidgetPoint> list = await widgetControl.search(value);
List<WidgetPoint> list = await DataUtils.searchWidget(value);
return list
.map((item) => new MaterialSearchResult<String>(
value: item.name,

View File

@ -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<IssuesMessagePage> {
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: <Widget>[
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: <Widget>[
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(),
),
);
}
}

View File

@ -34,7 +34,6 @@ class StandardView extends StatefulWidget {
_StandardView createState() => _StandardView();
}
class _StandardView extends State<StandardView> {
String markdownDesc = '';
String pageTitle = '';
@ -44,7 +43,6 @@ class _StandardView extends State<StandardView> {
StandardPages standardPage = new StandardPages();
@override
void initState() {
super.initState();
this.getPageInfo();
}
@ -62,7 +60,6 @@ class _StandardView extends State<StandardView> {
email = pageDetail['email'];
});
}
}
/// 从本地获取基本文章信息
@ -73,6 +70,7 @@ class _StandardView extends State<StandardView> {
return pagesList[pageId];
}
Future<String> getContentOnline() async {
String content = 'online content';
this.setState(() {
isLoading = true;
@ -115,9 +113,11 @@ class _StandardView extends State<StandardView> {
conent = localGetPagesMarkdown();
localGetPagesAttrsInfo();
}
setState(() {
markdownDesc = conent;
});
if (this.mounted) {
setState(() {
markdownDesc = conent;
});
}
return Future(() => conent);
}
Widget buildFootInfo() {
@ -135,6 +135,7 @@ class _StandardView extends State<StandardView> {
}
return Container();
}
Widget buildMarkdown() {
@ -150,20 +151,14 @@ class _StandardView extends State<StandardView> {
if (demo == null) {
String errString = "not found ${attrs['id']} in demo packages";
debugPrint(errString);
demo = [
Text(errString)
];
demo = [Text(errString)];
}
return Column(
children: demo
);
}
);
return Column(children: demo);
});
}
@override
Widget build(BuildContext context) {
return new WidgetDemo(
@ -181,4 +176,4 @@ class _StandardView extends State<StandardView> {
],
);
}
}
}

View File

@ -23,10 +23,6 @@ class WebViewPage extends StatefulWidget {
class _WebViewPageState extends State<WebViewPage> {
final flutterWebviewPlugin = new FlutterWebviewPlugin();
bool _hasCollected = false;
String _router = '';
var _collectionIcons;
CollectionControlModel _collectionControl = new CollectionControlModel();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
@override
@ -65,87 +61,14 @@ class _WebViewPageState extends State<WebViewPage> {
flutterWebviewPlugin.close();
}
});
// 这里 存放不使用 name 改成 url. 确定唯一性
// _collectionControl
// .getRouterByName(Uri.encodeComponent(widget.title.trim()))
// .then((list) {
// list.forEach((item) {
// if (widget.title.trim() == item['name']) {
// _router = item['router'];
// }
// });
// if (mounted) {
// setState(() {
// _hasCollected = list.length > 0;
// });
// }
// });
}
// 点击收藏按钮
_getCollection() {
if (_hasCollected) {
// 删除操作
_collectionControl
.deleteByName(Uri.encodeComponent(widget.title.trim()))
.then((result) {
if (result > 0 && this.mounted) {
setState(() {
_hasCollected = false;
});
_scaffoldKey.currentState
.showSnackBar(SnackBar(content: Text('已取消收藏')));
if (ApplicationEvent.event != null) {
ApplicationEvent.event
.fire(CollectionEvent(widget.title, _router, true));
}
return;
}
print('删除错误');
});
} else {
// 插入操作
_collectionControl
.insert(Collection(
name: Uri.encodeComponent(widget.title.trim()),
router: widget.url))
.then((result) {
if (this.mounted) {
setState(() {
_hasCollected = true;
});
if (ApplicationEvent.event != null) {
ApplicationEvent.event
.fire(CollectionEvent(widget.title, _router, false));
}
_scaffoldKey.currentState
.showSnackBar(SnackBar(content: Text('收藏成功')));
}
});
}
}
@override
Widget build(BuildContext context) {
if (_hasCollected) {
_collectionIcons = Icons.favorite;
} else {
_collectionIcons = Icons.favorite_border;
}
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(widget.title),
actions: <Widget>[
new IconButton(
tooltip: 'goBack home',
onPressed: _getCollection,
icon: Icon(
_collectionIcons,
),
),
],
),
body: WebviewScaffold(
url: widget.url,

View File

@ -1,364 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.2"
async:
dependency: transitive
description:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
bloc:
dependency: "direct main"
description:
name: bloc
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.2"
city_pickers:
dependency: "direct main"
description:
name: city_pickers
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.14.11"
cookie_jar:
dependency: "direct main"
description:
name: cookie_jar
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.16.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2"
dio:
dependency: "direct main"
description:
name: dio
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.13"
event_bus:
dependency: "direct main"
description:
name: event_bus
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
firebase_analytics:
dependency: "direct main"
description:
name: firebase_analytics
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0+1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.4"
fluro:
dependency: "direct main"
description:
name: fluro
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_bloc:
dependency: "direct main"
description:
name: flutter_bloc
url: "https://pub.dartlang.org"
source: hosted
version: "0.11.1"
flutter_downloader:
dependency: "direct main"
description:
name: flutter_downloader
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.9"
flutter_jpush:
dependency: "direct main"
description:
name: flutter_jpush
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4"
flutter_spinkit:
dependency: "direct main"
description:
name: flutter_spinkit
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_webview_plugin:
dependency: "direct main"
description:
name: flutter_webview_plugin
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.5"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
html:
dependency: "direct main"
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0+2"
image_picker:
dependency: "direct main"
description:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.4+3"
intl:
dependency: "direct main"
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.7"
lpinyin:
dependency: transitive
description:
name: lpinyin
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.7"
markdown:
dependency: "direct main"
description:
name: markdown
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.5"
meta:
dependency: "direct main"
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.6"
open_file:
dependency: "direct main"
description:
name: open_file
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
package_info:
dependency: "direct main"
description:
name: package_info
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0+3"
path:
dependency: "direct main"
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.2"
path_provider:
dependency: "direct main"
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
pedantic:
dependency: transitive
description:
name: pedantic
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.1+1"
quiver:
dependency: transitive
description:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
rxdart:
dependency: transitive
description:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
version: "0.21.0"
share:
dependency: "direct main"
description:
name: share
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.3"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.5"
sqflite:
dependency: "direct main"
description:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.6+2"
stack_trace:
dependency: transitive
description:
name: stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.3"
stream_channel:
dependency: transitive
description:
name: stream_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
string_scanner:
dependency: "direct main"
description:
name: string_scanner
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0+1"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.5"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.6"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.2"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
sdks:
dart: ">=2.2.2 <3.0.0"
flutter: ">=1.2.1 <2.0.0"

View File

@ -21,7 +21,7 @@ dependencies:
cupertino_icons: ^0.1.2
event_bus: ^1.0.1
fluro: ^1.3.4
image_picker: ^0.6.0
image_picker: ^0.5.0
sqflite: ^1.1.5
url_launcher: ^5.0.2
# 本地存储、收藏功能
@ -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:

72
zefyr/.gitignore vendored Normal file
View File

@ -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

3
zefyr/CHANGELOG.md Normal file
View File

@ -0,0 +1,3 @@
## [0.0.1] - TODO: Add release date.
* TODO: Describe initial release.

1
zefyr/LICENSE Normal file
View File

@ -0,0 +1 @@
TODO: Add your license here.

14
zefyr/README.md Normal file
View File

@ -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.

View File

@ -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"]';
}

View File

@ -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<HeadingButton> {
@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: <Widget>[
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<ImageButton> {
@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: <Widget>[
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<LinkButton> {
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 = <Widget>[Expanded(child: body)];
if (!isEditing) {
final unlinkHandler = hasLink(style) ? unlink : null;
final copyHandler = clipboardEnabled ? copyToClipboard : null;
final openHandler = hasLink(style) ? openInBrowser : null;
final buttons = <Widget>[
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: <Widget>[
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;
}
}

View File

@ -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);
}
}

View File

@ -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<Widget> 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);
}
}

View File

@ -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<RawZefyrLine> {
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<TextSpan> 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}');
}
}
}

View File

@ -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);
}
}

View File

@ -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<bool> _showCursor = new ValueNotifier<bool>(false);
ValueNotifier<bool> 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();
}
}
}

View File

@ -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<bool> 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<RenderEditableBox>,
RenderProxyBoxMixin<RenderEditableBox>
implements RenderEditableBox {
RenderEditableProxyBox({
RenderEditableBox child,
@required ContainerNode node,
@required LayerLink layerLink,
@required ZefyrRenderContext renderContext,
@required ValueNotifier<bool> 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<bool> _showCursor;
set showCursor(ValueNotifier<bool> 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<ui.TextBox> 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<ui.TextBox> 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
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
///
/// 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;
}
}

View File

@ -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<ZefyrEditableText>
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 = <Widget>[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<Widget> _buildChildren(BuildContext context) {
final result = <Widget>[];
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.
});
}
}

View File

@ -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<ZefyrEditor> {
ZefyrImageDelegate _imageDelegate;
ZefyrScope _scope;
ZefyrThemeData _themeData;
GlobalKey<ZefyrToolbarState> _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,
),
);
}
}

View File

@ -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<ZefyrField> {
@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(<Listenable>[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;
}
}

View File

@ -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<ui.TextBox> 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;
}
}

View File

@ -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<S> {
/// 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<String> pickImage(S source);
}
class ZefyrDefaultImageDelegate implements ZefyrImageDelegate<ImageSource> {
@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<String> 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<ZefyrImage> {
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<RenderBox>, RenderProxyBoxMixin<RenderBox>
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<ui.TextBox> 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();
}
}
}

View File

@ -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<TextEditingValue> _sentRemoteValues = [];
TextInputConnection _textInputConnection;
TextEditingValue _lastKnownRemoteTextEditingValue;
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
// TODO: implement updateFloatingCursor
}
}
class TextNode extends DiagnosticsNode{
@override
List<DiagnosticsNode> getChildren() {
// TODO: implement getChildren
return null;
}
@override
List<DiagnosticsNode> getProperties() {
// TODO: implement getProperties
return null;
}
@override
String toDescription({TextTreeConfiguration parentConfiguration}) {
// TODO: implement toDescription
return null;
}
@override
Object get value => 'while updating editing value';
}

View File

@ -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<Widget> 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: <Widget>[bullet, Expanded(child: content)],
);
}
}

View File

@ -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');
}
}

View File

@ -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<Widget> 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: <Widget>[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,
);
}
}

View File

@ -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<RenderEditableProxyBox> _dirtyBoxes = new Set();
final Set<RenderEditableProxyBox> _activeBoxes = new Set();
Set<RenderEditableProxyBox> get dirty => _dirtyBoxes;
Set<RenderEditableProxyBox> 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();
});
}
}

View File

@ -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<ui.TextBox> 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<ui.TextBox> _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;
}
}

View File

@ -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<ZefyrScaffold> {
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: <Widget>[
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;
}
}

View File

@ -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<bool> 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;
}
}

View File

@ -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<ZefyrSelectionOverlay>
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: <Widget>[
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<SelectionHandleDriver> {
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: <Widget>[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,
);
}
}

View File

@ -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,
);
}
}

View File

@ -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, NotusAttributeKey>{
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 = <Widget>[
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<ZefyrToolbar>
with SingleTickerProviderStateMixin {
final Key _toolbarKey = UniqueKey();
final Key _overlayKey = UniqueKey();
ZefyrToolbarDelegate _delegate;
AnimationController _overlayAnimation;
WidgetBuilder _overlayBuilder;
Completer<void> _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<void> showOverlay(WidgetBuilder builder) async {
assert(_overlayBuilder == null);
final completer = new Completer<void>();
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 = <Widget>[];
// 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<Widget> _buildButtons(BuildContext context) {
final buttons = <Widget>[
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<Widget> buttons;
@override
_ZefyrButtonListState createState() => _ZefyrButtonListState();
}
class _ZefyrButtonListState extends State<ZefyrButtonList> {
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: <Widget>[
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,
);
}
}
}

View File

@ -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<ZefyrView> {
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<Widget> _buildChildren(BuildContext context) {
final result = <Widget>[];
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.');
}
}

40
zefyr/lib/util.dart Normal file
View File

@ -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;
}

22
zefyr/lib/zefyr.dart Normal file
View File

@ -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';

59
zefyr/pubspec.yaml Normal file
View File

@ -0,0 +1,59 @@
name: zefyr
description: A new Flutter package project.
version: 0.0.1
author: Anatoly Pulyaevskiy <anatoly.pulyaevskiy@gmail.com>
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

View File

@ -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);
// });
}