dwyl / flutter-wysiwyg-editor-tutorial

๐Ÿ“ฑ๐Ÿ“ How to do WYSIWYG editing in Flutter in 5 easy steps. ๐Ÿš€
GNU General Public License v2.0
6 stars 0 forks source link
# `Flutter` `WYSIWYG` Editor Tutorial ๐Ÿ“ฑ ๐Ÿ“ How to do WYSIWYG ("What You See Is What You Get") editing in `Flutter` in a few easy steps. ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/flutter-wysiwyg-editor-tutorial/ci.yml?label=build&style=flat-square&branch=main) [![codecov.io](https://img.shields.io/codecov/c/github/dwyl/flutter-wysiwyg-editor-tutorial/master.svg?style=flat-square)](https://codecov.io/github/dwyl/flutter-wysiwyg-editor-tutorial?branch=master) [![HitCount](https://hits.dwyl.com/dwyl/flutter-wysiwyg-editor-tutorial.svg?style=flat-square&show=unique)](https://hits.dwyl.com/dwyl/flutter-wysiwyg-editor-tutorial) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/flutter-wysiwyg-editor-tutorial/issues) ![dwyl-wysiwyg-demo-optimized](https://github.com/dwyl/flutter-wysiwyg-editor-tutorial/assets/194400/08c6daff-33be-4f1a-9077-012cfd52b72c)


Why? ๐Ÿคทโ€

Our app, allows people to add items of text which get transformed into several types of list. e.g: Todo List, Shopping List, Exercises, etc. We need a capable editor that is easy-to-use and supports customization (new buttons) to allow them to easily transform their plaintext into richtext e.g: headings, bold, highlights, links and images!

What? ๐Ÿ’ญ

When typing text, the person using should be able to edit/format it to their heart's content and customize it to their liking.

This repo will showcase an introduction of a WYSIWYG Rich Text Editor that can be used on both Mobile and Web. We want this editor to be extensible, meaning that we want to add specific features and introduce them to the person incrementally.

Who? ๐Ÿ‘ค

This quick demo is aimed at people in the @dwyl team or anyone who is interested in learning more about building a WYSIWYG editor.

Note: this guide is meant only for Mobile devices and Web Apps. It is not tailored to Desktop apps. We are focusing on the Web and Mobile devices because it's more important to us and because it's simpler to understand. Some implementation details will need to be changed if you want this to work on desktop applications.

If you need this, please check the example app from flutter-quill, as they address this distinction.

How? ๐Ÿ‘ฉโ€๐Ÿ’ป

Prerequisites? ๐Ÿ“

This demo assumes you have foundational knowledge of Flutter. If this is your first time tinkering with Flutter, we suggest you first take a look at dwyl/learn-flutter

In the linked repo, you will learn how to install the needed dependencies and how to debug your app on both an emulator or a physical device.

0. Project setup

To create a new project in Flutter, follow the steps in https://github.com/dwyl/learn-flutter#0-setting-up-a-new-project.

After completing these steps, you should have a boilerplate Flutter project.

If you run the app, you should see the template Counter app. The tests should also run correctly. Executing flutter test --coverage should yield this output on the terminal.

00:02 +1: All tests passed!   

This means everything is correctly set up! We are ready to start implementing!

Make sure your Flutter is up-to-date!

Make sure you are running the latest version of Flutter! You can make a run-through of the versions by running:

flutter doctor

To make sure you're running the latest version, run flutter upgrade.

This is needed when running the app against physical devices. A minimum SDK version is needed to run the project with its dependencies so you don't encounter this error:

uses-sdk:minSdkVersion XX cannot be smaller than version XX declared in library

If you are still encountering this problem on your physical device, please follow the instructions in https://stackoverflow.com/questions/52060516/flutter-how-to-change-android-minsdkversion-in-flutter-project. You will essentially need to change the minSdkVersion parameter inside android/app/build.gradle file and bump it to a higher version (it is suggested in the error output).

1. Installing all the needed dependencies

To implement our application that runs flutter_quill, we are going to need install some dependencies that we're going to be using.

To install of these packages, head over to pubspec.yaml and inside the dependencies section, add the following lines:

  flutter_quill: ^7.3.3
  flutter_quill_extensions: ^0.4.0
  file_picker: ^5.3.3
  universal_io: ^2.2.2
  responsive_framework: ^1.1.0
  universal_html: ^2.2.3
  path: ^1.8.3
  path_provider: ^2.1.0

And run flutter pub get to download these dependencies.

2. Setting up the responsive framework

Now that we have all the dependencies we need, let's start by setting up all the needed breakpoints from the responsive_framework to make our app responsive and conditionally show elements according to the device size.

Head over to main.dart and paste the following:

import 'package:app/home_page.dart';
import 'package:flutter/material.dart';
import 'package:responsive_framework/responsive_framework.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(App(
    platformService: PlatformService(),
  ),);
}

/// Entry gateway to the application.
/// Defining the MaterialApp attributes and Responsive Framework breakpoints.
class App extends StatelessWidget {
  const App({required this.platformService, super.key});

  final PlatformService platformService;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Editor Demo',
      builder: (context, child) => ResponsiveBreakpoints.builder(
        child: child!,
        breakpoints: [
          const Breakpoint(start: 0, end: 425, name: MOBILE),
          const Breakpoint(start: 426, end: 768, name: TABLET),
          const Breakpoint(start: 769, end: 1440, name: DESKTOP),
          const Breakpoint(start: 1441, end: double.infinity, name: '4K'),
        ],
      ),
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
        useMaterial3: true,
      ),
      home: HomePage(platformService: platformService),
    );
  }
}

/// Platform service class that tells if the platform is web-based or not
class PlatformService {
  bool isWebPlatform() {
    return kIsWeb;
  }
}

Let's break this down!

This will fail because home_page.dart is not defined. Let's do that! ๐Ÿ˜€

3. Create HomePage with basic editor

In lib, create a file called home_page.dart and paste the following code.

/// Importing all the needed imports so the README is easier to follow
import 'dart:async';
import 'dart:io';
import 'dart:ui';

import 'package:app/main.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/extensions.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';

/// Home page with the `flutter-quill` editor
class HomePage extends StatefulWidget {
  const HomePage({
    required this.platformService, super.key,
  });

  final PlatformService platformService;

  @override
  HomePageState createState() => HomePageState();
}

class HomePageState extends State<HomePage> {

  /// `flutter-quill` editor controller
  QuillController? _controller;

  /// Focus node used to obtain keyboard focus and events
  final FocusNode _focusNode = FocusNode();

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    /// Loading widget if controller's not loaded
    if (_controller == null) {
      return const Scaffold(body: Center(child: Text('Loading...')));
    }

    /// Returning scaffold with editor as body
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        centerTitle: false,
        title: const Text(
          'Flutter Quill',
        ),
      ),
      body: Container(),
    );
  }
}

We are importing all the needed imports right off the bat so we don't have to deal with those later ๐Ÿ˜‰.

In this file, we are simply creating a basic stateful widget HomePage that renders a Scaffold widget with a Container() as its body.

You might have noticed we've defined a QuillController _controller. This controller will keep track of the deltas and the text that is written in the editor.

[!NOTE]
Deltas is an object format that represents contents and changes in a readable way. To learn about them, we highly suggest visiting https://quilljs.com/docs/delta.

For the Quill Editor to properly function, we are going to use this _controller parameter later on. The _focusNode parameter is also needed in the next section.

4. Adding the Quill editor

Now here comes to fun part! Let's instantiate the editor so it can be shown on screen!

Inside HomePageState, we are going to instantiate and define our editor and render it on its body. So, let's do that!

Inside the build() function, change it to this:

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        centerTitle: false,
        title: const Text(
          'Flutter Quill',
        ),
      ),
      body: _buildEditor(),
    );

Now, inside HomePageState, let's create this _buildEditor() function, which will return the Quill Editor widget! ๐Ÿฅณ

  Widget _buildEditor(BuildContext context) {

  }

Now, let's start implementing our editor!

4.1 Instantiating QuillEditor

QuillEditor is the main class in which we define the behaviour we want from our editor.

In _buildEditor, add this:

    Widget quillEditor = QuillEditor(
      controller: _controller!,
      scrollController: ScrollController(),
      scrollable: true,
      focusNode: _focusNode,
      autoFocus: false,
      readOnly: false,
      placeholder: 'Write what\'s on your mind.',
      enableSelectionToolbar: isMobile(),
      expands: false,
      padding: EdgeInsets.zero,
      onTapUp: (details, p1) {
        return _onTripleClickSelection();
      },
      customStyles: DefaultStyles(
        h1: DefaultTextBlockStyle(
          const TextStyle(
            fontSize: 32,
            color: Colors.black,
            height: 1.15,
            fontWeight: FontWeight.w300,
          ),
          const VerticalSpacing(16, 0),
          const VerticalSpacing(0, 0),
          null,
        ),
        sizeSmall: const TextStyle(fontSize: 9),
        subscript: const TextStyle(
          fontFamily: 'SF-UI-Display',
          fontFeatures: [FontFeature.subscripts()],
        ),
        superscript: const TextStyle(
          fontFamily: 'SF-UI-Display',
          fontFeatures: [FontFeature.superscripts()],
        ),
      ),
      embedBuilders: [...FlutterQuillEmbeds.builders()],
    );

As you may see, the QuillEditor class can take a lot of parameters.

So let's go over them one by one!

4.2 Defining triple click selection behaviour

We can see the behaviour of triple clicking on text and selecting different pieces of text everywhere on the internet. If you use browsers like Chrome, if you click on a word two times, the word is selected. If you click again (three clicks), a whole paragraph is selected.

We can customize what we select in the onTapUp parameter of QuillEditor. This is what we're going to be doing in _onTripleClickSelection().

Add the following line on top of the home_page.dart file, under the imports.

enum _SelectionType {
  none,
  word,
}

This _SelectionType enum is going to be used inside _onTripleClickSelection() to switch over the selections as the person clicks on the text.

Now, inside HomePageState (outside _buildEditor() function), let's create the _onTripleClickSelection() function.

  /// Callback called whenever the person taps on the text.
  /// It will select nothing, then the word if another tap is detected
  /// and then the whole text if another tap is detected (triple).
  bool _onTripleClickSelection() {
    final controller = _controller!;

    // If nothing is selected, selection type is `none`
    if (controller.selection.isCollapsed) {
      _selectionType = _SelectionType.none;
    }

    // If nothing is selected, selection type becomes `word
    if (_selectionType == _SelectionType.none) {
      _selectionType = _SelectionType.word;
      return false;
    }

    // If the word is selected, select all text
    if (_selectionType == _SelectionType.word) {
      final child = controller.document.queryChild(
        controller.selection.baseOffset,
      );
      final offset = child.node?.documentOffset ?? 0;
      final length = child.node?.length ?? 0;

      final selection = TextSelection(
        baseOffset: offset,
        extentOffset: offset + length,
      );

      // Select all text and make next selection to `none`
      controller.updateSelection(selection, ChangeSource.REMOTE);

      _selectionType = _SelectionType.none;

      return true;
    }

    return false;
  }

In this function, we check the current state of the selection. If there is no selection in the controller, we set the _SelectionType to none.

On the second tap (meaning the current _SelectionType is none), we set the _SelectionType to word.

On the third tap (meaning the current _SelectionType is word), we select the whole text in the editor. For this, we get the text offset from the controller and use it to set the text selection to the whole text. We then set the _selectionType back to none, so on the next click, the selection loops back to nothing.

4.3 Reassigning quillEditor on web platforms

In order for our editor to work on the web, we need to make a few changes to the quillEditor variable. Because of this, we are going to reassign a new QuillEditor instance to it on web platforms.

Go back to _buildEditor() and continue where we left off. Add the following line under the quillEditor variable instantiation.

    if (widget.platformService.isWebPlatform()) {
      quillEditor = QuillEditor(
        controller: _controller!,
        scrollController: ScrollController(),
        scrollable: true,
        focusNode: _focusNode,
        autoFocus: false,
        readOnly: false,
        placeholder: 'Add content',
        expands: false,
        padding: EdgeInsets.zero,
        onTapUp: (details, p1) {
          return _onTripleClickSelection();
        },
        customStyles: DefaultStyles(
          h1: DefaultTextBlockStyle(
            const TextStyle(
              fontSize: 32,
              color: Colors.black,
              height: 1.15,
              fontWeight: FontWeight.w300,
            ),
            const VerticalSpacing(16, 0),
            const VerticalSpacing(0, 0),
            null,
          ),
          sizeSmall: const TextStyle(fontSize: 9),
        ),
        embedBuilders: [...defaultEmbedBuildersWeb],
      );
    }

We are using the platformService we mentioned earlier to check if the platform is web or not.

As you can see, the parameters are quite similar to the previous assignment (meant only for mobile devices), except the embedBuilders parameter, which uses the defaultEmbedBuildersWeb. This variable is not yet defined, so we shall do this right now!

4.3.1 Creating web embeds

As noted in flutter-quill's documentation (https://github.com/singerdmx/flutter-quill/tree/master#web), we need to define web embeds so Quill Editor works properly.

If we want to embed an image on a web-based platform and show it to the person, we need to define our own embed. For this, create a folder called web_embeds inside lib. Create a file called web_embeds.dart and paste the following code.


import 'package:app/main.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:universal_html/html.dart' as html;

// Conditionally importing the PlatformViewRegistry class according to the platform
import 'mobile_platform_registry.dart' if (dart.library.html) 'web_platform_registry.dart' as ui_instance;

/// Custom embed for images to work on the web.
class ImageEmbedBuilderWeb extends EmbedBuilder {
  @override
  String get key => BlockEmbed.imageType;

  @override
  Widget build(
    BuildContext context,
    QuillController controller,
    Embed node,
    bool readOnly,
    bool inline,
    TextStyle textStyle,
  ) {
    final imageUrl = node.value.data;
    if (isImageBase64(imageUrl)) {
      // TODO: handle imageUrl of base64
      return const SizedBox();
    }
    final size = MediaQuery.of(context).size;

    // This is needed for images to be correctly embedded on the web.
    ImageUniversalUI().platformViewRegistry.registerViewFactory(PlatformService(), imageUrl, (viewId) {
      return html.ImageElement()
        ..src = imageUrl
        ..style.height = 'auto'
        ..style.width = 'auto';
    });

    // Rendering responsive image
    return Padding(
      padding: EdgeInsets.only(
        right: ResponsiveBreakpoints.of(context).smallerThan(TABLET)
            ? size.width * 0.5
            : (ResponsiveBreakpoints.of(context).equals('4K'))
                ? size.width * 0.75
                : size.width * 0.2,
      ),
      child: SizedBox(
        height: MediaQuery.of(context).size.height * 0.45,
        child: HtmlElementView(
          viewType: imageUrl,
        ),
      ),
    );
  }
}

/// List of default web embed builders.
List<EmbedBuilder> get defaultEmbedBuildersWeb => [
      ImageEmbedBuilderWeb(),
    ];

This is where we define defaultEmbedBuildersWeb we're using in _buildEditor(). This array variable uses the ImageEmbedBuilderWeb, our custom web embed so images are shown in web platforms. We technically can add more embeds (for example, to show videos). But for now, let's keep it simple and only allow the person to add images.

The ImageEmbedBuilderWeb class pertains to the web image embed, extending the EmbedBuilder class from flutter-quill.

Let's break it down. We define the key of the class of what type of object the embed pertains to. In our case, it's an image.

  @override
  String get key => BlockEmbed.imageType;

In the build function, we render the widget we want in the screen. In this case, we render an ImageElement with the imageURL that is passed to the class. We use ResponsiveBreakpoints from responsive_framework to show properly show the image across different device sizes.

Inside the build() function, you may notice the following lines:

    ImageUniversalUI().platformViewRegistry.registerViewFactory(PlatformService(), imageUrl, (viewId) {
      return html.ImageElement()
        ..src = imageUrl
        ..style.height = 'auto'
        ..style.width = 'auto';
    });

We need to call registerViewFactory from dart:ui_web so the image is properly shown in web devices. If we do not do this, build compilation fails. This is because the package it's called from doesn't compile when we create a build for mobile devices. This is why we create a ImageUniversalUI class that conditionally imports the package so it compiles on both type of devices. For more information, check https://github.com/flutter/flutter/issues/41563#issuecomment-547923478.

For this to work, in the same file, add this piece of code:

/// Class used to conditionally register the view factory.
/// For more information, check https://github.com/flutter/flutter/issues/41563#issuecomment-547923478.
class PlatformViewRegistryFix {
  void registerViewFactory(PlatformService platformService, imageURL, dynamic cbFnc) {
    if (platformService.isWebPlatform()) {
      ui_instance.PlatformViewRegistry.registerViewFactory(
        imageURL,
        cbFnc,
      );
    }
  }
}

/// Class that conditionally registers the `platformViewRegistry`.
class ImageUniversalUI {
  PlatformViewRegistryFix platformViewRegistry = PlatformViewRegistryFix();
}

PlatformViewRegistryFix calls the registerViewFactory only on web platforms. It uses the ui_instance object, which is conditionally imported on top of the file. This ui_instance variable uses the appropriate package to call registerViewFactory.

[!NOTE] Check https://github.com/flutter/flutter/issues/41563#issuecomment-547923478 for more information about dart:ui and dart:web_ui to better understand why we need to conditionally import them separately so the application compiles to both target devices.

This is why we import the correct ui_instance so we can compile to both targets web and mobile devices.

import 'mobile_platform_registry.dart' if (dart.library.html) 'web_platform_registry.dart' as ui_instance;

Neither of these files are created. So let's do that! In the same folder lib/web_embeds, create mobile_platform_registry.dart and add:

/// Class used to `registerViewFactory` for mobile platforms.
/// 
/// Please check https://github.com/flutter/flutter/issues/41563#issuecomment-547923478 for more information.
class PlatformViewRegistry {
  static void registerViewFactory(String viewId, dynamic cb) {}
}

This is just a simple class with registerViewFactory function that effectively does nothing. We don't need for it to do anything because we are implementing the embed only for the web. So we just only need this to compile.

Now, in the same folder, create the file web_platform_registry.dart and add:

import 'dart:ui_web' as web_ui;

/// Class used to `registerViewFactory` for web platforms.
/// 
/// Please check https://github.com/flutter/flutter/issues/41563#issuecomment-547923478 for more information.
class PlatformViewRegistry {
  static void registerViewFactory(String viewId, dynamic cb) {
    web_ui.platformViewRegistry.registerViewFactory(viewId, cb);
  }
}

In here, we are importing dart:ui_web (which only compiles on web devices) and performing the registerViewFactory.

And that's it! We've successfully created a web embed that embeds image on the web!

To recap:

To recap, here's how lib/web_embeds/web_embeds.dart should look like.

import 'package:app/main.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:universal_html/html.dart' as html;

// Conditionally importing the PlatformViewRegistry class according to the platform
import 'mobile_platform_registry.dart' if (dart.library.html) 'web_platform_registry.dart' as ui_instance;

/// Class used to conditionally register the view factory.
/// For more information, check https://github.com/flutter/flutter/issues/41563#issuecomment-547923478.
class PlatformViewRegistryFix {
  void registerViewFactory(PlatformService platformService, imageURL, dynamic cbFnc) {
    if (platformService.isWebPlatform()) {
      ui_instance.PlatformViewRegistry.registerViewFactory(
        imageURL,
        cbFnc,
      );
    }
  }
}

/// Class that conditionally registers the `platformViewRegistry`.
class ImageUniversalUI {
  PlatformViewRegistryFix platformViewRegistry = PlatformViewRegistryFix();
}

/// Custom embed for images to work on the web.
class ImageEmbedBuilderWeb extends EmbedBuilder {
  @override
  String get key => BlockEmbed.imageType;

  @override
  Widget build(
    BuildContext context,
    QuillController controller,
    Embed node,
    bool readOnly,
    bool inline,
    TextStyle textStyle,
  ) {
    final imageUrl = node.value.data;
    if (isImageBase64(imageUrl)) {
      // TODO: handle imageUrl of base64
      return const SizedBox();
    }
    final size = MediaQuery.of(context).size;

    // This is needed for images to be correctly embedded on the web.
    ImageUniversalUI().platformViewRegistry.registerViewFactory(PlatformService(), imageUrl, (viewId) {
      return html.ImageElement()
        ..src = imageUrl
        ..style.height = 'auto'
        ..style.width = 'auto';
    });

    // Rendering responsive image
    return Padding(
      padding: EdgeInsets.only(
        right: ResponsiveBreakpoints.of(context).smallerThan(TABLET)
            ? size.width * 0.5
            : (ResponsiveBreakpoints.of(context).equals('4K'))
                ? size.width * 0.75
                : size.width * 0.2,
      ),
      child: SizedBox(
        height: MediaQuery.of(context).size.height * 0.45,
        child: HtmlElementView(
          viewType: imageUrl,
        ),
      ),
    );
  }
}

/// List of default web embed builders.
List<EmbedBuilder> get defaultEmbedBuildersWeb => [
      ImageEmbedBuilderWeb(),
    ];

4.4 Creating the toolbar

Now that we've defined the editor and the appropriate web embeds so images work on web devices, it's time to create our toolbar.

This toolbar will have some options for the person to stylize the text (e.g make it bold or italic) and add images (which make use of the embed we've just created for web devices). We can add custom buttons if we want to.

To define our toolbar, we're going to be using QuillToolbar. This class has an option where one can define a toolbar easily, using QuillToolbar.basic(). This will render a myriad of features that, for this example, we do not need.

Because we only want a handful of features, we're going to define QuillToolbar normally.

Go back to _buildEditor() and continue where you left off. Let's instantiate our editor.

    // Toolbar definitions
    const toolbarIconSize = 18.0;
    final embedButtons = FlutterQuillEmbeds.buttons(
      // Showing only necessary default buttons
      showCameraButton: false,
      showFormulaButton: false,
      showVideoButton: false,
      showImageButton: true,

      // `onImagePickCallback` is called after image (from any platform) is picked
      onImagePickCallback: _onImagePickCallback,

      // `webImagePickImpl` is called after image (from web) is picked and then `onImagePickCallback` is called
      webImagePickImpl: _webImagePickImpl,

      // defining the selector (we only want to open the gallery whenever the person wants to upload an image)
      mediaPickSettingSelector: (context) {
        return Future.value(MediaPickSetting.Gallery);
      },
    );

    // Instantiating the toolbar
    final toolbar = QuillToolbar(
      afterButtonPressed: _focusNode.requestFocus,
      children: [
        HistoryButton(
          icon: Icons.undo_outlined,
          iconSize: toolbarIconSize,
          controller: _controller!,
          undo: true,
        ),
        HistoryButton(
          icon: Icons.redo_outlined,
          iconSize: toolbarIconSize,
          controller: _controller!,
          undo: false,
        ),
        ToggleStyleButton(
          attribute: Attribute.bold,
          icon: Icons.format_bold,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          attribute: Attribute.italic,
          icon: Icons.format_italic,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          attribute: Attribute.underline,
          icon: Icons.format_underline,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          attribute: Attribute.strikeThrough,
          icon: Icons.format_strikethrough,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        for (final builder in embedButtons) builder(_controller!, toolbarIconSize, null, null),
      ],
    );

Let's go over piece by piece ๐Ÿ˜ƒ.

This array of embed buttons are used in the definition of the toolbar QuillToolbar, which is made right afterwards.

We've mentioned the functions onImagePickCallback and webImagePickImpl when defining the array of custom embed buttons. However, we haven't yet defined them. Let's do that!

4.4.1 Defining image embed callbacks

In HomePageState, add the two needed functions.

  Future<String> _onImagePickCallback(File file) async {

    if (!widget.platformService.isWebPlatform()) {
      // Copies the picked file from temporary cache to applications directory
      final appDocDir = await getApplicationDocumentsDirectory();
      final copiedFile = await file.copy('${appDocDir.path}/${basename(file.path)}');
      return copiedFile.path.toString();
    } else {
      // TODO: This doesn't work on the web yet.
      // This is because Flutter on the web (browsers) does not have the path of local files.

      // But because of the web embeds we've created, we *know* this works.
      // You can try returning a link to see it working.

      //return "https://pbs.twimg.com/media/EzmJ_YBVgAEnoF2?format=jpg&name=large";

      return file.path;
    }
  }

  Future<String?> _webImagePickImpl(OnImagePickCallback onImagePickCallback) async {
    // Lets the user pick one file; files with any file extension can be selected
    final result = await FilePicker.platform.pickFiles(type: FileType.image);

    // The result will be null, if the user aborted the dialog
    if (result == null || result.files.isEmpty) {
      return null;
    }

    // Read file as bytes (https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ#q-how-do-i-access-the-path-on-web)
    final platformFile = result.files.first;
    final bytes = platformFile.bytes;

    if (bytes == null) {
      return null;
    }

    final file = File.fromRawPath(bytes);
    return onImagePickCallback(file);
  }

4.5 Finishing editor

Now that we have the proper callback implemented, all that's left is finish our _buildEditor() function! ๐Ÿ˜Š

All we need to do is return the widget that will render the editor on the page.

    return SafeArea(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: <Widget>[
          Expanded(
            flex: 15,
            child: Container(
              key: quillEditorKey,
              color: Colors.white,
              padding: const EdgeInsets.only(left: 16, right: 16),
              child: quillEditor,
            ),
          ),
          Container(child: toolbar),
        ],
      ),
    );

As you can see, we are using the quillEditor inside the Expanded widget to take all the space. Below it, we render a Container with the toolbar we've defined.

And that's it!

Your lib/home_page.dart file should now look like so:

import 'dart:async';
import 'dart:io';
import 'dart:ui';

import 'package:app/main.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/extensions.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';

import 'web_embeds/web_embeds.dart';

const quillEditorKey = Key('quillEditorKey');

/// Types of selection that person can make when triple clicking
enum _SelectionType {
  none,
  word,
}

/// Home page with the `flutter-quill` editor
class HomePage extends StatefulWidget {
  const HomePage({
    required this.platformService, super.key,
  });

  final PlatformService platformService;

  @override
  HomePageState createState() => HomePageState();
}

class HomePageState extends State<HomePage> {
  /// `flutter-quill` editor controller
  QuillController? _controller;

  /// Focus node used to obtain keyboard focus and events
  final FocusNode _focusNode = FocusNode();

  /// Selection types for triple clicking
  _SelectionType _selectionType = _SelectionType.none;

  @override
  void initState() {
    super.initState();
    _initializeText();
  }

  /// Initializing the [Delta](https://quilljs.com/docs/delta/) document with sample text.
  Future<void> _initializeText() async {
    // final doc = Document()..insert(0, 'Just a friendly empty text :)');
    final doc = Document();
    setState(() {
      _controller = QuillController(document: doc, selection: const TextSelection.collapsed(offset: 0));
    });
  }

  @override
  Widget build(BuildContext context) {
    /// Loading widget if controller's not loaded
    if (_controller == null) {
      return const Scaffold(body: Center(child: Text('Loading...')));
    }

    /// Returning scaffold with editor as body
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        centerTitle: false,
        title: const Text(
          'Flutter Quill',
        ),
      ),
      body: _buildEditor(context),
    );
  }

  /// Callback called whenever the person taps on the text.
  /// It will select nothing, then the word if another tap is detected
  /// and then the whole text if another tap is detected (triple).
  bool _onTripleClickSelection() {
    final controller = _controller!;

    // If nothing is selected, selection type is `none`
    if (controller.selection.isCollapsed) {
      _selectionType = _SelectionType.none;
    }

    // If nothing is selected, selection type becomes `word
    if (_selectionType == _SelectionType.none) {
      _selectionType = _SelectionType.word;
      return false;
    }

    // If the word is selected, select all text
    if (_selectionType == _SelectionType.word) {
      final child = controller.document.queryChild(
        controller.selection.baseOffset,
      );
      final offset = child.node?.documentOffset ?? 0;
      final length = child.node?.length ?? 0;

      final selection = TextSelection(
        baseOffset: offset,
        extentOffset: offset + length,
      );

      // Select all text and make next selection to `none`
      controller.updateSelection(selection, ChangeSource.REMOTE);

      _selectionType = _SelectionType.none;

      return true;
    }

    return false;
  }

  /// Build the `flutter-quill` editor to be shown on screen.
  Widget _buildEditor(BuildContext context) {
    // Default editor (for mobile devices)
    Widget quillEditor = QuillEditor(
      controller: _controller!,
      scrollController: ScrollController(),
      scrollable: true,
      focusNode: _focusNode,
      autoFocus: false,
      readOnly: false,
      placeholder: 'Write what\'s on your mind.',
      enableSelectionToolbar: isMobile(),
      expands: false,
      padding: EdgeInsets.zero,
      onTapUp: (details, p1) {
        return _onTripleClickSelection();
      },
      customStyles: DefaultStyles(
        h1: DefaultTextBlockStyle(
          const TextStyle(
            fontSize: 32,
            color: Colors.black,
            height: 1.15,
            fontWeight: FontWeight.w300,
          ),
          const VerticalSpacing(16, 0),
          const VerticalSpacing(0, 0),
          null,
        ),
        sizeSmall: const TextStyle(fontSize: 9),
        subscript: const TextStyle(
          fontFamily: 'SF-UI-Display',
          fontFeatures: [FontFeature.subscripts()],
        ),
        superscript: const TextStyle(
          fontFamily: 'SF-UI-Display',
          fontFeatures: [FontFeature.superscripts()],
        ),
      ),
      embedBuilders: [...FlutterQuillEmbeds.builders()],
    );

    // Alternatively, the web editor version is shown  (with the web embeds)
    if (widget.platformService.isWebPlatform()) {
      quillEditor = QuillEditor(
        controller: _controller!,
        scrollController: ScrollController(),
        scrollable: true,
        focusNode: _focusNode,
        autoFocus: false,
        readOnly: false,
        placeholder: 'Add content',
        expands: false,
        padding: EdgeInsets.zero,
        onTapUp: (details, p1) {
          return _onTripleClickSelection();
        },
        customStyles: DefaultStyles(
          h1: DefaultTextBlockStyle(
            const TextStyle(
              fontSize: 32,
              color: Colors.black,
              height: 1.15,
              fontWeight: FontWeight.w300,
            ),
            const VerticalSpacing(16, 0),
            const VerticalSpacing(0, 0),
            null,
          ),
          sizeSmall: const TextStyle(fontSize: 9),
        ),
        embedBuilders: [...defaultEmbedBuildersWeb],
      );
    }

    // Toolbar definitions
    const toolbarIconSize = 18.0;
    final embedButtons = FlutterQuillEmbeds.buttons(
      // Showing only necessary default buttons
      showCameraButton: false,
      showFormulaButton: false,
      showVideoButton: false,
      showImageButton: true,

      // `onImagePickCallback` is called after image (from any platform) is picked
      onImagePickCallback: _onImagePickCallback,

      // `webImagePickImpl` is called after image (from web) is picked and then `onImagePickCallback` is called
      webImagePickImpl: _webImagePickImpl,

      // defining the selector (we only want to open the gallery whenever the person wants to upload an image)
      mediaPickSettingSelector: (context) {
        return Future.value(MediaPickSetting.Gallery);
      },
    );

    // Instantiating the toolbar
    final toolbar = QuillToolbar(
      afterButtonPressed: _focusNode.requestFocus,
      children: [
        HistoryButton(
          icon: Icons.undo_outlined,
          iconSize: toolbarIconSize,
          controller: _controller!,
          undo: true,
        ),
        HistoryButton(
          icon: Icons.redo_outlined,
          iconSize: toolbarIconSize,
          controller: _controller!,
          undo: false,
        ),
        ToggleStyleButton(
          attribute: Attribute.bold,
          icon: Icons.format_bold,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          attribute: Attribute.italic,
          icon: Icons.format_italic,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          attribute: Attribute.underline,
          icon: Icons.format_underline,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        ToggleStyleButton(
          attribute: Attribute.strikeThrough,
          icon: Icons.format_strikethrough,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),
        for (final builder in embedButtons) builder(_controller!, toolbarIconSize, null, null),
      ],
    );

    // Rendering the final editor + toolbar
    return SafeArea(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: <Widget>[
          Expanded(
            flex: 15,
            child: Container(
              key: quillEditorKey,
              color: Colors.white,
              padding: const EdgeInsets.only(left: 16, right: 16),
              child: quillEditor,
            ),
          ),
          Container(child: toolbar),
        ],
      ),
    );
  }

  // Renders the image picked by imagePicker from local file storage
  // You can also upload the picked image to any server (eg : AWS s3
  // or Firebase) and then return the uploaded image URL.
  Future<String> _onImagePickCallback(File file) async {
    //return "https://pbs.twimg.com/media/EzmJ_YBVgAEnoF2?format=jpg&name=large";
    if (!widget.platformService.isWebPlatform()) {
      // Copies the picked file from temporary cache to applications directory
      final appDocDir = await getApplicationDocumentsDirectory();
      final copiedFile = await file.copy('${appDocDir.path}/${basename(file.path)}');
      return copiedFile.path.toString();
    } else {
      // TODO: This will fail on web
      // Might have to upload to S3 or embed a canvas like https://stackoverflow.com/questions/71798042/flutter-how-do-i-write-a-file-to-local-directory-with-path-provider.

      return file.path;
    }
  }

  /// Callback that is called after an image is picked whilst on the web platform.
  Future<String?> _webImagePickImpl(OnImagePickCallback onImagePickCallback) async {
    // Lets the user pick one file; files with any file extension can be selected
    final result = await FilePicker.platform.pickFiles(type: FileType.image);

    // The result will be null, if the user aborted the dialog
    if (result == null || result.files.isEmpty) {
      return null;
    }

    // Read file as bytes (https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ#q-how-do-i-access-the-path-on-web)
    final platformFile = result.files.first;
    final bytes = platformFile.bytes;

    if (bytes == null) {
      return null;
    }

    final file = File.fromRawPath(bytes);

    return onImagePickCallback(file);
  }
}

Congratulations! ๐Ÿฅณ

We just got ourselves a fancy editor working in our application!

5. Getting images to work on the web

As we've mentioned before, we currently can't get the images to correctly show in the editor upon prompting the person to select an image.

As we've stated before, the reason images don't show up on the web is because we don't have the concept of local file paths. So the browser is not able to render the image.

However, we can leverage dwyl-imgup to upload the image and render it according to the provided URL where the image is hosted.

Let's get to work!

5.1 Install the needed dependencies

Before doing this, let's install some dependencies. Add these lines to pubspec.yaml, in the dependencies section.

  http: ^0.13.6
  mime: ^1.0.4
  http_parser: ^4.0.2

5.2 Change the _webImagePickImpl callback function

The _webImagePickImpl callback is invoked when the person picks an image on the web platform.

Therefore, we are going to use the array of bytes and use it to get the MIME type of the file and create a MultipartRequest to the imgup server.

  Future<String?> _webImagePickImpl(OnImagePickCallback onImagePickCallback) async {
    // Lets the user pick one file; files with any file extension can be selected
    final result = await ImageFilePicker().pickImage();

    // The result will be null, if the user aborted the dialog
    if (result == null || result.files.isEmpty) {
      return null;
    }

    // Read file as bytes (https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ#q-how-do-i-access-the-path-on-web)
    final platformFile = result.files.first;
    final bytes = platformFile.bytes;

    if (bytes == null) {
      return null;
    }

    // Make HTTP request to upload the image to the file
    const apiURL = 'https://imgup.fly.dev/api/images';
    final request = http.MultipartRequest('POST', Uri.parse(apiURL));

    final httpImage = http.MultipartFile.fromBytes('image', bytes,
        contentType: MediaType.parse(lookupMimeType('', headerBytes: bytes)!), filename: platformFile.name);
    request.files.add(httpImage);

    // Check the response and handle accordingly
    return http.Client().send(request).then((response) async {
      if (response.statusCode != 200) {
        return null;
      }

      final responseStream = await http.Response.fromStream(response);
      final responseData = json.decode(responseStream.body);
      return responseData['url'];
    });
  }

We receive the response from imgup and, depending on whether the upload was successful or not, we retrieve the url where the image is hosted.

If the upload fails, we return null, the same way we return null when the person cancels the upload. Therefore, nothing happens.

5.3 Change the _onImagePickCallback callback function

Now that _webImagePickImpl isn't invoking _onImagePickCallback, we don't need to conditionally check if the platform is web-based or not.

Therefore, change it to the following.

  Future<String> _onImagePickCallback(File file) async {
      final appDocDir = await getApplicationDocumentsDirectory();
      final copiedFile = await file.copy('${appDocDir.path}/${basename(file.path)}');
      return copiedFile.path.toString();
  }

Because the function is only called on mobile devices, we know for sure that it will run correctly every time.

6. Give the app a whirl

Now let's see our app in action! If you run the application, you should see something like this!

https://github.com/dwyl/flutter-wysiwyg-editor-tutorial/assets/17494745/e859328e-3ae4-4195-b9d6-54d3490cfba1

As you can see, the person can:

There are many more options one can implement using flutter-quill, including font-size, indentation, highlighting and many more! Please check https://github.com/singerdmx/flutter-quill for this.

[!NOTE]

If you are having trouble executing on an iPhone device, please follow the instructions in https://github.com/dwyl/learn-flutter#ios.

In our case, we had to add the following lines to ios/Runner/Info.plist.

<key>NSPhotoLibraryAddUsageDescription</key>
<string>Needs gallery access to embed images</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Needs gallery access to embed images</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>

You need to do this through XCode. Check the following image to add these lines through XCode. If you don't, Apple's binary decoder might have some trouble interpreting your changed Info.plist file.

xcode

7. Extending our toolbar

As it stands, our toolbar offers limited options. We want it to do more! Let's add these features so the person using our app is free to customize the text further ๐Ÿ˜Š.

7.1 Header font sizes

Let's start by adding different header font sizes. This will allow the person to better organize their items. We will provide three different headers (h1, h2 and h3), each one with decreasing sizes and vertical spacings.

These subheadings will be toggleable from the toolbar.

Let's add the buttons to the toolbar. Locate the toolbar variable (with type QuillToolbar) inside the _buildEditor() function.

In the children parameter, add the SelectHeaderStyleButton after the HistoryButtons. Like so:

final toolbar = QuillToolbar(
  afterButtonPressed: _focusNode.requestFocus,
  children: [
    HistoryButton(
      icon: Icons.undo_outlined,
      iconSize: toolbarIconSize,
      controller: _controller!,
      undo: true,
    ),
    HistoryButton(
      icon: Icons.redo_outlined,
      iconSize: toolbarIconSize,
      controller: _controller!,
      undo: false,
    ),

    // Add this button
    SelectHeaderStyleButton(
      controller: _controller!,
      axis: Axis.horizontal,
      iconSize: toolbarIconSize,
      attributes: const [Attribute.h1, Attribute.h2, Attribute.h3],
    ),

    //  rest of the buttons
  ]
)

With this button, we will be able to define the subheadings we want to make available to the person. The axis parameter defines whether they should be sorted horizontally or vertically. The attributes field defines how many subheadings we want to add. In our case, we'll just define three.

Now we need to define the styling for the headings. For this, we need to change the customStyles field of the quillEditor variable (from the QuillEditor class ) inside _buildEditor().

We are going to make these changes to both mobile and web quillEditor variables. Locate them at check the customStyles field. Change it to the following:

customStyles: DefaultStyles(
        // Change these -------------
        h1: DefaultTextBlockStyle(
          const TextStyle(
            fontSize: 32,
            color: Colors.black,
            height: 1.15,
            fontWeight: FontWeight.w600,
          ),
          const VerticalSpacing(16, 0),
          const VerticalSpacing(0, 0),
          null,
        ),
        h2: DefaultTextBlockStyle(
          const TextStyle(
            fontSize: 24,
            color: Colors.black87,
            height: 1.15,
            fontWeight: FontWeight.w600,
          ),
          const VerticalSpacing(8, 0),
          const VerticalSpacing(0, 0),
          null,
        ),
        h3: DefaultTextBlockStyle(
          const TextStyle(
            fontSize: 20,
            color: Colors.black87,
            height: 1.25,
            fontWeight: FontWeight.w600,
          ),
          const VerticalSpacing(8, 0),
          const VerticalSpacing(0, 0),
          null,
        ),
        // Change these -------------
        // ....
),

We have changed the pre-existing h1 field and added the h2 and h3 fields as well, specifying different font weights and sizes and colour for each subheading.

And that's it! That's all you need to do to change the subheadings!

Awesome job! ๐Ÿ‘

7.2 Adding emojis

Let's add a button that will allow people to add emojis! This is useful for both mobile and web platforms (it's more relevant on the latter, as there is not a native emoji keyboard to choose from).

You might be wondering that, for mobile applications, having a dedicated button to insert emojis is redudant, because iOS and Android devices offer a native keyboard in which you can select an emoji and insert it as text.

However, we're doing this for two purposes:

So let's do this!

First, let's install the package we'll use to select emojis. Simply run flutter pub add emoji_picker_flutter and all the dependencies will be installed.

Now that's done with, let's start by creating our emoji picker. Let's first create our widget in a separate file. Inside lib, create a file called emoji_picker_widget.dart.

import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:responsive_framework/responsive_framework.dart';

/// Emoji picker widget that is offstage.
/// Shows an emoji picker when [offstageEmojiPicker] is `false`.
class OffstageEmojiPicker extends StatefulWidget {
  /// `QuillController` controller that is passed so the controller document is changed when emojis are inserted.
  final QuillController? quillController;

  /// Determines if the emoji picker is offstage or not.
  final bool offstageEmojiPicker;

  const OffstageEmojiPicker({required this.offstageEmojiPicker, this.quillController, super.key});

  @override
  State<OffstageEmojiPicker> createState() => _OffstageEmojiPickerState();
}

class _OffstageEmojiPickerState extends State<OffstageEmojiPicker> {
  /// Returns the emoji picker configuration according to screen size.
  Config _buildEmojiPickerConfig(BuildContext context) {
    if (ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)) {
      return const Config(emojiSizeMax: 32.0, columns: 7, recentTabBehavior: RecentTabBehavior.NONE);
    }

    if (ResponsiveBreakpoints.of(context).equals(TABLET)) {
      return const Config(emojiSizeMax: 24.0, columns: 10, recentTabBehavior: RecentTabBehavior.NONE);
    }

    if (ResponsiveBreakpoints.of(context).equals(DESKTOP)) {
      return const Config(emojiSizeMax: 16.0, columns: 15, recentTabBehavior: RecentTabBehavior.NONE);
    }

    return const Config(emojiSizeMax: 16.0, columns: 30, recentTabBehavior: RecentTabBehavior.NONE);
  }

  @override
  Widget build(BuildContext context) {
    return Offstage(
      offstage: widget.offstageEmojiPicker,
      child: SizedBox(
        height: 250,
        child: EmojiPicker(
          onEmojiSelected: (category, emoji) {
            if (widget.quillController != null) {
              // Get pointer selection and insert emoji there
              final selection = widget.quillController?.selection;
              widget.quillController?.document.insert(selection!.end, emoji.emoji);

              // Update the pointer after the emoji we've just inserted
              widget.quillController?.updateSelection(TextSelection.collapsed(offset: selection!.end + emoji.emoji.length), ChangeSource.REMOTE);
            }
          },
          config: _buildEmojiPickerConfig(context),
        ),
      ),
    );
  }
}

Let's unpack what we've just implemented. The widget we've create is a stateful widget that receives two parameters:

In the build() function, we use the Offstage class to wrap the widget. This will make it possible to show and hide the emoji picker accordingly.

We then use the EmojiPicker widget from the package we've just downloaded. In this widget, we define two parameters:

Now all that's left is to use our newly created widget in our homepage! Head over to lib/home_page.dart, and add a new field inside HomePageState.

  /// Show emoji picker
  bool _offstageEmojiPickerOffstage = true;

In the same class, we're going to create a callback function that is to be called every time the person clicks on the emoji toolbar button (don't worry, we'll create this button in a minute). This function will close the keyboard and open the emoji picker widget we've just created.

  void _onEmojiButtonPressed(BuildContext context) {
    final isEmojiPickerShown = !_offstageEmojiPickerOffstage;

    // If emoji picker is being shown, we show the keyboard and hide the emoji picker.
    if (isEmojiPickerShown) {
      _focusNode.requestFocus();
      setState(() {
        _offstageEmojiPickerOffstage = true;
      });
    }

    // Otherwise, we do the inverse.
    else {
      // Unfocusing when the person clicks away. This is to hide the keyboard.
      // See https://flutterigniter.com/dismiss-keyboard-form-lose-focus/
      // and https://www.youtube.com/watch?v=MKrEJtheGPk&t=40s&ab_channel=HeyFlutter%E2%80%A4com.
      final currentFocus = FocusScope.of(context);
      if (!currentFocus.hasPrimaryFocus) {
        SystemChannels.textInput.invokeMethod('TextInput.hide');
      }

      setState(() {
        _offstageEmojiPickerOffstage = false;
      });
    }
  }

We are toggling the _offstageEmojiPickerOffstage field by calling setState(), thus causing a re-render and properly toggling the emoji picker.

Now all we need to do is add the button to the toolbar to toggle the emoji picker and add the offstage emoji picker to the widget tree.

Let's do the first one. Locate _buildEditor and find the toolbar (class QuillToolbar) definition. In the children parameter, we're going to add a CustomButton to these buttons.

final toolbar = QuillToolbar(
  afterButtonPressed: _focusNode.requestFocus,
  children: [
    CustomButton(
      onPressed: () => _onEmojiButtonPressed(context),
      icon: Icons.emoji_emotions,
      iconSize: toolbarIconSize,
    ),

    // Other buttons...
  ]
)

As you can see, we are calling the _onEmojiButtonPressed function we've implemented every time the person taps on the emoji button.

At the end of the function, we're going to return the editor with the OffstageEmojiPicker widget we've initially created.

    return SafeArea(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: <Widget>[
          Expanded(
            flex: 15,
            child: Container(
              key: quillEditorKey,
              color: Colors.white,
              padding: const EdgeInsets.only(left: 16, right: 16),
              child: quillEditor,
            ),
          ),
          Container(child: toolbar),

          // Add this ---
          OffstageEmojiPicker(
            offstageEmojiPicker: _offstageEmojiPickerOffstage,
            quillController: _controller,
          ),
        ],
      ),
    );

And that's it! We've just successfully added an emoji picker that is correctly toggled when clicking the appropriate button in the toolbar, and adding the correct changes to the Delta document of the Quill editor.

7.3 Adding embeddable links

This one's the easiest. flutter-quill already provides a specific button which we can invoke that'll do all the work for us, including formatting, embedding the link and properly adding the change to the controller's document.

Simply add the following snippet of code to the children field of the toolbar variable you've worked with earlier.

       // ....
       ToggleStyleButton(
          attribute: Attribute.strikeThrough,
          icon: Icons.format_strikethrough,
          iconSize: toolbarIconSize,
          controller: _controller!,
        ),

        // Add this button
        LinkStyleButton(
          controller: _controller!,
          iconSize: toolbarIconSize,
          linkRegExp: RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'),
        ),

        for (final builder in embedButtons) builder(_controller!, toolbarIconSize, null, null),

And that's it! We're using the LinkStyleButton class with a regular expression that we've defined ourselves that will only allow a link to be added if it's valid.

8. Custom image button

As we've shown before, when initializing the toolbar, we can set if we want to show the ImageButton or not.

final embedButtons = FlutterQuillEmbeds.buttons(
      // Showing only necessary default buttons
      showCameraButton: false,
      showFormulaButton: false,
      showVideoButton: false,
      showImageButton: true,

      // `onImagePickCallback` is called after image is picked on mobile platforms
      onImagePickCallback: _onImagePickCallback,

      // `webImagePickImpl` is called after image is picked on the web
      webImagePickImpl: _webImagePickImpl,

      // defining the selector (we only want to open the gallery whenever the person wants to upload an image)
      mediaPickSettingSelector: (context) {
        return Future.value(MediaPickSetting.Gallery);
      },
    );

However, although this is handy and allows us to quickly embed images in our documents, it makes it impossible for us to test it because its functionality is dependent on checking the kIsWeb constant. Unfortunately, it is impossible to override this constant when widget testing without mocking it.

Because kIsWeb is being used within flutter-quill, it's impossible for us to mock it, therefore making it impossible for our widget tests to cover the part of codes that use web embeds.

This is not desirable for us or for anyone that values the merits of code testing.

Luckily, we can circumvent this situation by adding our own CustomButton that has the same behaviour as the original ImageButton.

And this is what we'll do it in this section ๐Ÿ˜€.

8.1 Creating the new custom button

Let's create our awesome new custom button. In lib, create image_button_widget.dart and use the following code.

import 'dart:convert';
import 'dart:io';

import 'package:app/main.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:http_parser/http_parser.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:http/http.dart' as http;

const imageButtonKey = Key('imageButtonKey');

/// Image button shown in the toolbar to embed images in the editor
class ImageToolbarButton extends StatelessWidget {
  // Dependency injectors
  final http.Client client;
  final PlatformService platformService;
  final ImageFilePicker imageFilePicker;

  // Quill arguments
  final double toolbarIconSize;
  final QuillController controller;

  const ImageToolbarButton({
    required this.toolbarIconSize,
    required this.platformService,
    required this.imageFilePicker,
    required this.controller,
    required this.client, super.key,
  });

  @override
  Widget build(BuildContext context) {
    return CustomButton(
      key: imageButtonKey,
      onPressed: () async {
        final result = await imageFilePicker.pickImage();

        // The result will be null, if the user aborted the dialog
        if (result == null || result.files.isEmpty) {
          return;
        }

        // Check if it is web-based or not and act accordingly
        String? imagePath;
        if (platformService.isWebPlatform()) {
          imagePath = await _webPickCallback(result);
        } else {
          imagePath = await _onMobileCallback(result);
        }

        if (imagePath == null) {
          return;
        }

        // Embed the image in the editor
        final index = controller.selection.baseOffset;
        final length = controller.selection.extentOffset - index;
        controller.replaceText(index, length, BlockEmbed.image(imagePath), null);
      },
      icon: Icons.image,
      iconSize: toolbarIconSize,
    );
  }

  /// Returns the file path of the chosen file on mobile platforms.
  Future<String> _onMobileCallback(FilePickerResult result) async {
    final file = File(result.files.single.path!);

    final appDocDir = await getApplicationDocumentsDirectory();
    final path = '${appDocDir.path}/${basename(file.path)}';
    final copiedFile = await file.copy(path);

    return copiedFile.path.toString();
  }

  /// Callback that is called after an image is picked whilst on the web platform.
  /// Returns the URL path of the image.
  /// Returns null if an error occurred uploading the file or the image was not picked.
  Future<String?> _webPickCallback(FilePickerResult result) async {
    // Read file as bytes (https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ#q-how-do-i-access-the-path-on-web)
    final platformFile = result.files.first;
    final bytes = platformFile.bytes;

    if (bytes == null) {
      return null;
    }

    // Make HTTP request to upload the image to the file
    const apiURL = 'https://imgup.fly.dev/api/images';
    final request = http.MultipartRequest('POST', Uri.parse(apiURL));

    final httpImage = http.MultipartFile.fromBytes(
      'image',
      bytes,
      contentType: MediaType.parse(lookupMimeType('', headerBytes: bytes)!),
      filename: platformFile.name,
    );
    request.files.add(httpImage);

    // Check the response and handle accordingly
    return client.send(request).then((response) async {
      if (response.statusCode != 200) {
        return null;
      }

      final responseStream = await http.Response.fromStream(response);
      final responseData = json.decode(responseStream.body);
      return responseData['url'];
    });
  }
}

Let's go over each aspect of this new widget ImageToolbarButton.

And that's it! Now we need to use our widget, and make some changes to our toolbar variable initialization.

8.2 Using our new custom button in HomePage

Head over to lib/home_page.dart. We're going to use our widget, so we need to make some changes to it.

First, we need to receive instances of the http.Client and ImageFilePicker classes as parameters. These will serve as dependency injectors to later be passed on to our ImageToolbarButton custom button.

[!NOTE]

We have to pass down these dependency down the widget tree. There are alternatives to avoid doing this. Check https://github.com/dwyl/learn-flutter#dependency-injection for more information and alternatives to make dependency injection much easier.

We're avoiding adding other libraries to this example, as they are out of the scope of this demo.

class HomePage extends StatefulWidget {
  const HomePage({
    required this.platformService,
    required this.imageFilePicker,
    required this.client, super.key,
  });

  /// Platform service used to check if the user is on mobile.
  final PlatformService platformService;

  /// Image file picker service that opens File Picker and returns result
  final ImageFilePicker imageFilePicker;

  /// HTTP client used to make network requests
  final http.Client client;

  @override
  HomePageState createState() => HomePageState();
}

Now, inside HomePageState, locate the _buildEditor function. Delete the embedButtons variable. It will not be needed because we are not going to be using any default flutter-quill embed buttons, since we're using our new custom one.

In the toolbar variable of the QuillToolbar class, the children parameter will be changed. At the end of the array, delete the loop that adds the previously deleted embedButtons and replace it with our new custom widget ImageToolbarButton.

[

// ...
ImageToolbarButton(
  controller: _controller!,
  client: widget.client,
  imageFilePicker: widget.imageFilePicker,
  platformService: widget.platformService,
  toolbarIconSize: toolbarIconSize,
),
]

In this very same file, you can also safely delete the _onImagePickCallback and _webImagePickImpl functions, as they are no longer needed.

Your file should now look like lib/home_page.dart

8.3 Small changes in main.dart

Because HomePage now receives a few additional dependency injectors, we need to pass them in our main.dart file, where we set up the whole application.

Change it to the following:

import 'package:app/home_page.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:http/http.dart' as http;

// coverage:ignore-start
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(
    App(
      client: http.Client(),
      platformService: PlatformService(),
      imageFilePicker: ImageFilePicker(),
    ),
  );
}
// coverage:ignore-end

/// Entry gateway to the application.
/// Defining the MaterialApp attributes and Responsive Framework breakpoints.
class App extends StatelessWidget {
  const App({required this.platformService, required this.imageFilePicker, required this.client, super.key});

  final PlatformService platformService;
  final ImageFilePicker imageFilePicker;
  final http.Client client;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Editor Demo',
      builder: (context, child) => ResponsiveBreakpoints.builder(
        child: child!,
        breakpoints: [
          const Breakpoint(start: 0, end: 425, name: MOBILE),
          const Breakpoint(start: 426, end: 768, name: TABLET),
          const Breakpoint(start: 769, end: 1440, name: DESKTOP),
          const Breakpoint(start: 1441, end: double.infinity, name: '4K'),
        ],
      ),
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
        useMaterial3: true,
      ),
      home: HomePage(
        client: client,
        platformService: platformService,
        imageFilePicker: imageFilePicker,
      ),
    );
  }
}

// coverage:ignore-start
/// Platform service class that tells if the platform is web-based or not
class PlatformService {
  bool isWebPlatform() {
    return kIsWeb;
  }
}

class ImageFilePicker {
  Future<FilePickerResult?> pickImage() => FilePicker.platform.pickFiles(type: FileType.image);
}
// coverage:ignore-end

And you're done! Awesome job! ๐ŸŽ‰

8.4 Rendering image instead of web-specific HTML element

In lib/web_embeds/web_embeds.dart, we are rendering the image like so:

child: HtmlElementView(
  viewType: imageUrl,
),

Although this is useful in web-based platforms, it will make it impossible for us to test our widgets when using this because this class is only available in web-specific platforms.

You would get an error like so:

The following assertion was thrown building HtmlElementView(dirty):
HtmlElementView is only available on Flutter Web.
'package:flutter/src/widgets/platform_view.dart':
Failed assertion: line 366 pos 12: 'kIsWeb'

Because of this, it is best for us to use another class that effectively does the same thing. In fact, Flutter recommends us using HtmlElementView sparingly. Therefore, let's use the Image class!

Simply change it to:

child: Image.network(imageUrl),

And you're done! It will still work on both platforms. The only discernable difference is that, with the previous option, one could right-click on the image and save to the computer. Instead, the image is now part of the Flutter canvas that is drawn as part of Flutter's web renderers.

A note about testing ๐Ÿงช

We try to get tests covering 100% of the lines of code in every repository we make. However, it is worth mentioning that, due to lack of documentation and testing from flutter-quill, it becomes difficult to do so in this project.

This is why the coverage is not at a 100% in this repo. This is mainly because we aren't able to simulate a person choosing an image when the gallery pops up in widgetTests.

Because on mobile devices, flutter-quill uses image-picker under the hood, it is impossible to directly mock it.

In addition to this, we have inclusively tried overriding its behaviour with platform channels and using setMockMethodCallHandler, but to no avail.

We've opened an issue on flutter-quill about this. You can visit it in https://github.com/singerdmx/flutter-quill/issues/1389 if you want more information about this issue.

Therefore, pieces of code related to image and video embeds aren't being covered by the tests. This includes functions like _onImagePickCallback and _webImagePickImpl. It also includes custom web embeds, which means the class ImageEmbedBuilderWeb is also not covered.

Alternative editors

There are a myriad of alternative editors that you can use in Flutter. We've chosen this one because it offers us the option to get Delta files, which allows us to see text contents and changes throughout its lifetime.

However, there are other editors that you may consider:

We've created a specific folder that will help you migrate the code you've just implemented from flutter-quill to visual-editor.

You can check the finished migrated application and the guide in alt_visual-editor.

Found this useful?

If you found this example useful, please โญ๏ธ the GitHub repository so we (and others) know you liked it!

Any questions or suggestions? Do not hesitate to open new issues!

Thank you!