Flutter 征服 富文本编辑器zefyr
Flutter : Channel stable, 1.22.1
zefyr : ^1.0.0-dev.1.0
** 完整代码在最后,正文略过不看的可看最后完整代码 **
一、写作背景
最近几年一直在写项目,一直没怎么写博客了。一是懒,二是忙,只想一心快速推进项目,毕竟写博客也很费心力。
但是因为zefyr刚升级到1.0.0, 文档不太完善,官方案例也不全,自己也是看了源代码才明白如何应用。写下这篇文章一是作为备忘记录,二是也分享给有需要的朋友。废话不多说,进入正文。
二、整合
首先需要知道的是本文针对的是zefyr 1.0.0, 这个版本跟0.X是完全不一样的。
1、 第一步, 调用编辑器
ZefyrEditor(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
embedBuilder: customZefyrEmbedBuilder, // embedBuilder是处理图片上传的function
// readOnly: true, // readOnly 就比较明显,编辑状态还是只读状态
),
第二步,自定义toolbar
官方案例没有指导我们如何自定义toolbar, 我也是看了源代码才知道 。 以下代码就是从zefyr源代码里拷出来的,然后需要什么按钮就自己去增删。
var toolbar = ZefyrToolbar(children:[
ToggleStyleButton(
attribute: NotusAttribute.bold,
icon: Icons.format_bold,
controller: _controller,
),
SizedBox(width: 1),
ToggleStyleButton(
attribute: NotusAttribute.italic,
icon: Icons.format_italic,
controller: _controller,
),
VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400),
SelectHeadingStyleButton(controller: _controller),
VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400),
ToggleStyleButton(
attribute: NotusAttribute.block.numberList,
controller: _controller,
icon: Icons.format_list_numbered,
),
ToggleStyleButton(
attribute: NotusAttribute.block.bulletList,
controller: _controller,
icon: Icons.format_list_bulleted,
),
VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400),
ToggleStyleButton(
attribute: NotusAttribute.block.quote,
controller: _controller,
icon: Icons.format_quote,
),
CustomInsertImageButton( // 自定义图片上传组件
controller: _controller,
icon: Icons.image,
),]);
Expanded(
child: Container(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: ZefyrEditor(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
embedBuilder: customZefyrEmbedBuilder,
),
),
),
SingleChildScrollView( // 溢出屏幕可滚动
scrollDirection: Axis.horizontal,
child: toolbar ),
第三步,自定义图片上传按钮
当我升级到1.0之后 ,发现这个的实现逻辑跟0.X完全不一样,在github上问了作者,作者回复说会过几天写个demo出来。所以我就又又又硬着头皮去研究源代码。以下是实现的代码
Widget customZefyrEmbedBuilder(BuildContext context, EmbedNode node) {
if ( node.value.type.contains('http://')) {
return Container(
width: MediaQuery.of(context).size.width,
child: GestureDetector(
child: Image.network(
node.value.type,
fit: BoxFit.fill
),
onTap: () {
//Navigator.push(context, MaterialPageRoute(builder: (_) {
// return DetailScreen(node.value.type);
//}
)
);
},
),
);
}
return Container();
}
这里需要说明一下 node.value.type.contains('http://')
这行代码是什么意思。
因为我发现zefyr的toolbar,你点击每一个按钮都会调用 embedbuilder(也就是总是会调用customZefyrEmbedBuilder),然后会传入 node.value.type 这个变量。这种情况下我们无法去判断用户是不是点击了图片上传按钮,然后来处理相关逻辑。
然后我就想到可以利用 node.value.type ,查看了源码后,我发现可以自己实现这个按钮,让它传入特定的参数。这样我们就可以判断了。
class CustomInsertImageButton extends StatelessWidget {
final ZefyrController controller;
final IconData icon;
const CustomInsertImageButton({
Key key,
@required this.controller,
@required this.icon,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ZIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: 32,
icon: Icon(
icon,
size: 18,
color: Theme.of(context).iconTheme.color,
),
fillColor: Theme.of(context).canvasColor,
onPressed: () {
final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index;
ImageSource gallerySource = ImageSource.gallery;
final image = pickImage(gallerySource);
image.then((value) => {
controller.replaceText(index, length, BlockEmbed(value) )
} );
},
);
}
}
主要就是以下这几行,从image picker取得图片,上传到服务器之后 ,会取得图片地址 。然后作为node.value.type的值传入embedbuilder function 。
image.then((value) => {
controller.replaceText(index, length, BlockEmbed(value) )
} );
以上的 BlockEmbed(value)
就来到下面这里,图片地址是 xxxxxxx.img ,所以我们以这个来判断是不是用户上传了图片,
if ( node.value.type.contains('http://')) {
}
三、完整代码
import 'dart:convert';
import 'dart:io';
import 'package:bilingualapp/plugin/image_picker-0.6.7+11/lib/image_picker.dart';
import 'package:bilingualapp/util/print.dart';
import 'package:flutter/material.dart';
import 'package:quill_delta/quill_delta.dart';
import 'package:zefyr/zefyr.dart';
import 'package:path/path.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import 'package:async/async.dart';
class EditorPage extends StatefulWidget {
final ZefyrController _controller;
final bool _editing ;
EditorPage(this._controller,this._editing);
@override
EditorPageState createState() => EditorPageState(_controller,this._editing);
}
class EditorPageState extends State<EditorPage> {
ZefyrController _controller;
FocusNode _focusNode;
bool _editing = false;
EditorPageState(this._controller,this._editing);
@override
void initState() {
super.initState();
Delta()..insert('Karl', {'bold': true})
..insert(' the ')
..insert('Fog', {'italic': true});
if (_controller == null ){
final document = _loadDocument();
_controller = ZefyrController(document);
_controller.addListener((){
final contents = jsonEncode(_controller.document);
});
}
_focusNode = FocusNode();
}
Widget _buildWelcomeEditor(BuildContext context) {
var toolbar = ZefyrToolbar(children:[
ToggleStyleButton(
attribute: NotusAttribute.bold,
icon: Icons.format_bold,
controller: _controller,
),
SizedBox(width: 1),
ToggleStyleButton(
attribute: NotusAttribute.italic,
icon: Icons.format_italic,
controller: _controller,
),
VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400),
SelectHeadingStyleButton(controller: _controller),
VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400),
ToggleStyleButton(
attribute: NotusAttribute.block.numberList,
controller: _controller,
icon: Icons.format_list_numbered,
),
ToggleStyleButton(
attribute: NotusAttribute.block.bulletList,
controller: _controller,
icon: Icons.format_list_bulleted,
),
VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400),
ToggleStyleButton(
attribute: NotusAttribute.block.quote,
controller: _controller,
icon: Icons.format_quote,
),
CustomInsertImageButton(
controller: _controller,
icon: Icons.image,
),]);
return Column(
children: [
Divider(height: 1, thickness: 1, color: Colors.grey.shade200),
Expanded(
child: Container(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: ZefyrEditor(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
embedBuilder: customZefyrEmbedBuilder,
// readOnly: true,
// padding: EdgeInsets.only(left: 16, right: 16),
// onLaunchUrl: _launchUrl,
),
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: toolbar ),
],
);
}
@override
Widget build(BuildContext context) {
return Expanded(
child:_buildWelcomeEditor(context)
);
}
NotusDocument _loadDocument() {
final Delta delta = Delta()..insert("\n");
return NotusDocument.fromJson(delta.toJson());
}
}
Widget customZefyrEmbedBuilder(BuildContext context, EmbedNode node) {
if ( node.value.type.contains('http://')) {
return Container(
width: MediaQuery.of(context).size.width,
child: GestureDetector(
child: Image.network(
node.value.type,
fit: BoxFit.fill
),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) {
return DetailScreen(node.value.type);
}
)
);
},
),
);
}
return Container();
}
class CustomInsertImageButton extends StatelessWidget {
final ZefyrController controller;
final IconData icon;
const CustomInsertImageButton({
Key key,
@required this.controller,
@required this.icon,
}) : super(key: key);
Future<String> upload(File imageFile) async {
// open a bytestream
var stream = http.ByteStream(DelegatingStream.typed(imageFile.openRead()));
// get file length
var length = await imageFile.length();
// string to uri
var uri = Uri.parse(server + "/upload");
// create multipart request
var request = http.MultipartRequest("POST", uri);
// multipart that takes file
var multipartFile = http.MultipartFile('note', stream, length,
filename: basename(imageFile.path));
// add file to multipart
request.files.add(multipartFile);
// send
var response = await request.send();
// listen for response.join()
return response.stream.transform(utf8.decoder).join();
}
Future<String> pickImage(ImageSource source) async {
final file = await ImagePicker.pickImage(source: source,imageQuality: 65);
if (file == null) return null;
String value = await upload(file);
var v = jsonDecode(value);
var url = server + "/" + v["data"]["filepath"];
print(url);
return url;
}
@override
Widget build(BuildContext context) {
return ZIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: 32,
icon: Icon(
icon,
size: 18,
color: Theme.of(context).iconTheme.color,
),
fillColor: Theme.of(context).canvasColor,
onPressed: () {
final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index;
ImageSource gallerySource = ImageSource.gallery;
// controller.replaceText(index, length, BlockEmbed.image("https://img.alicdn.com/imgextra/i1/6000000003634/O1CN01XkL17h1ciPvkUalkW_!!6000000003634-2-octopus.png",));
final image = pickImage(gallerySource);
image.then((value) => {
controller.replaceText(index, length, BlockEmbed(value) )
} );
},
);
}
}
class DetailScreen extends StatelessWidget {
String _image = "";
DetailScreen(this._image);
@override
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
child: Center(
child: Hero(
tag: 'imageHero',
child: Image.network(
_image,
fit: BoxFit.contain
)
),
),
onTap: () {
Navigator.pop(context);
},
),
);
}
}
个人主页: YEE领域
背单词Flutter APP: 领域英语APP
转载自:https://juejin.cn/post/6883682688784564231