mirror of
https://github.com/alibaba/flutter-go.git
synced 2025-05-19 22:06:31 +08:00
tep
This commit is contained in:
@ -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';//搜索组件
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
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('收藏成功')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
try {
|
||||
var response = await NetUtils.get(Api.GET_WIDGET_TREE);
|
||||
|
||||
if (response['success']) {
|
||||
print('组件树:$response');
|
||||
if (response != null && response['success']) {
|
||||
return response['data'];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
print('获取组件树 error $error');
|
||||
}
|
||||
}
|
||||
|
||||
// 校验是否收藏
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>[
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
152
lib/views/issuse_message_page/issuse_message_page.dart
Normal file
152
lib/views/issuse_message_page/issuse_message_page.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
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,18 +151,12 @@ 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
|
||||
|
@ -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,
|
||||
|
364
pubspec.lock
364
pubspec.lock
@ -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"
|
@ -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
72
zefyr/.gitignore
vendored
Normal 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
3
zefyr/CHANGELOG.md
Normal file
@ -0,0 +1,3 @@
|
||||
## [0.0.1] - TODO: Add release date.
|
||||
|
||||
* TODO: Describe initial release.
|
1
zefyr/LICENSE
Normal file
1
zefyr/LICENSE
Normal file
@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
14
zefyr/README.md
Normal file
14
zefyr/README.md
Normal 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.
|
40
zefyr/lib/src/fast_diff.dart
Normal file
40
zefyr/lib/src/fast_diff.dart
Normal 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"]';
|
||||
}
|
583
zefyr/lib/src/widgets/buttons.dart
Normal file
583
zefyr/lib/src/widgets/buttons.dart
Normal 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;
|
||||
}
|
||||
}
|
42
zefyr/lib/src/widgets/caret.dart
Normal file
42
zefyr/lib/src/widgets/caret.dart
Normal 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);
|
||||
}
|
||||
}
|
47
zefyr/lib/src/widgets/code.dart
Normal file
47
zefyr/lib/src/widgets/code.dart
Normal 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);
|
||||
}
|
||||
}
|
155
zefyr/lib/src/widgets/common.dart
Normal file
155
zefyr/lib/src/widgets/common.dart
Normal 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}');
|
||||
}
|
||||
}
|
||||
}
|
177
zefyr/lib/src/widgets/controller.dart
Normal file
177
zefyr/lib/src/widgets/controller.dart
Normal 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);
|
||||
}
|
||||
}
|
42
zefyr/lib/src/widgets/cursor_timer.dart
Normal file
42
zefyr/lib/src/widgets/cursor_timer.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
341
zefyr/lib/src/widgets/editable_box.dart
Normal file
341
zefyr/lib/src/widgets/editable_box.dart
Normal 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;
|
||||
}
|
||||
}
|
283
zefyr/lib/src/widgets/editable_text.dart
Normal file
283
zefyr/lib/src/widgets/editable_text.dart
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
164
zefyr/lib/src/widgets/editor.dart
Normal file
164
zefyr/lib/src/widgets/editor.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
86
zefyr/lib/src/widgets/field.dart
Normal file
86
zefyr/lib/src/widgets/field.dart
Normal 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;
|
||||
}
|
||||
}
|
121
zefyr/lib/src/widgets/horizontal_rule.dart
Normal file
121
zefyr/lib/src/widgets/horizontal_rule.dart
Normal 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;
|
||||
}
|
||||
}
|
236
zefyr/lib/src/widgets/image.dart
Normal file
236
zefyr/lib/src/widgets/image.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
198
zefyr/lib/src/widgets/input.dart
Normal file
198
zefyr/lib/src/widgets/input.dart
Normal 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';
|
||||
|
||||
}
|
86
zefyr/lib/src/widgets/list.dart
Normal file
86
zefyr/lib/src/widgets/list.dart
Normal 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)],
|
||||
);
|
||||
}
|
||||
}
|
68
zefyr/lib/src/widgets/paragraph.dart
Normal file
68
zefyr/lib/src/widgets/paragraph.dart
Normal 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');
|
||||
}
|
||||
}
|
55
zefyr/lib/src/widgets/quote.dart
Normal file
55
zefyr/lib/src/widgets/quote.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
146
zefyr/lib/src/widgets/render_context.dart
Normal file
146
zefyr/lib/src/widgets/render_context.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
223
zefyr/lib/src/widgets/rich_text.dart
Normal file
223
zefyr/lib/src/widgets/rich_text.dart
Normal 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;
|
||||
}
|
||||
}
|
60
zefyr/lib/src/widgets/scaffold.dart
Normal file
60
zefyr/lib/src/widgets/scaffold.dart
Normal 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;
|
||||
}
|
||||
}
|
232
zefyr/lib/src/widgets/scope.dart
Normal file
232
zefyr/lib/src/widgets/scope.dart
Normal 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;
|
||||
}
|
||||
}
|
512
zefyr/lib/src/widgets/selection.dart
Normal file
512
zefyr/lib/src/widgets/selection.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
312
zefyr/lib/src/widgets/theme.dart
Normal file
312
zefyr/lib/src/widgets/theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
398
zefyr/lib/src/widgets/toolbar.dart
Normal file
398
zefyr/lib/src/widgets/toolbar.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
107
zefyr/lib/src/widgets/view.dart
Normal file
107
zefyr/lib/src/widgets/view.dart
Normal 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
40
zefyr/lib/util.dart
Normal 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
22
zefyr/lib/zefyr.dart
Normal 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
59
zefyr/pubspec.yaml
Normal 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
|
13
zefyr/test/zefyr_test.dart
Normal file
13
zefyr/test/zefyr_test.dart
Normal 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);
|
||||
// });
|
||||
}
|
Reference in New Issue
Block a user