阅读 2078

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


复制代码

捕获5.PNG

个人主页: YEE领域

背单词Flutter APP: 领域英语APP

文章分类
前端
文章标签