A rich text editor for Flutter App, flutter_quil
FlutterQuill is a rich text editor and a Quill component for Flutter.
This library is a WYSIWYG editor built for the modern mobile platform, with web compatibility under development. You can join our Slack Group for discussion.
Demo App: https://bulletjournal.us/home/index.html
Pub: https://pub.dev/packages/flutter_quill
Contents
Usage
See the example
directory for a minimal example of how to use FlutterQuill. You typically just need to instantiate a controller:
QuillController _controller = QuillController.basic();
and then embed the toolbar and the editor, within your app. For example:
Column( children: [ QuillToolbar.basic(controller: _controller), Expanded( child: Container( child: QuillEditor.basic( controller: _controller, readOnly: false, // true for view only mode ), ), ) ], )
Check out Sample Page for advanced usage.
Input / Output
This library uses Quill as an internal data format.
- Use
_controller.document.toDelta()
to extract the deltas. - Use
_controller.document.toPlainText()
to extract plain text.
FlutterQuill provides some JSON serialisation support, so that you can save and open documents. To save a document as JSON, do something like the following:
var json = jsonEncode(_controller.document.toDelta().toJson());
You can then write this to storage.
To open a FlutterQuill editor with an existing JSON representation that you’ve previously stored, you can do something like this:
var myJSON = jsonDecode(incomingJSONText);
_controller = QuillController(
document: Document.fromJson(myJSON),
selection: TextSelection.collapsed(offset: 0));
Configuration
The QuillToolbar
class lets you customise which formatting options are available. Sample Page provides sample code for advanced usage and configuration.
Web
For web development, use flutter config --enable-web
for flutter and use ReactQuill for React.
It is required to provide EmbedBuilder
, e.g. defaultEmbedBuilderWeb. Also it is required to provide webImagePickImpl
, e.g. Sample Page.
Desktop
It is required to provide filePickImpl
for toolbar image button, e.g. Sample Page.
Custom Size Image for Mobile
Define mobileWidth
, mobileHeight
, mobileMargin
, mobileAlignment
as follows:
{
"insert": {
"image": "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png"
},
"attributes":{
"style":"mobileWidth: 50; mobileHeight: 50; mobileMargin: 10; mobileAlignment: topLeft"
}
}
Translation of toolbar
The package offers translations for the quill toolbar, it will follow the system locale unless you set your own locale with:
QuillToolbar(locale: Locale('fr'), ...)
Currently, translations are available for these locales:
Locale('en')
Locale('ar')
Locale('de')
Locale('da')
Locale('fr')
Locale('zh', 'CN')
Locale('ko')
Locale('ru')
Locale('es')
Locale('tr')
Locale('uk')
Contributing to translations
The translation file is located at lib/src/translations/toolbar.i18n.dart. Feel free to contribute your own translations, just copy the English translations map and replace the values with your translations. Then open a pull request so everyone can benefit from your translations!
Example
- Create a flutter app, using flutter command: flutter create app flutter_quill. Here flutter_quill being the name of the app.
- In the lib folder is where we store all our .dart codes.
- Create file named main.dart.
- Create pages, universal_ui, and widgets folders in lib itself.
- Create files inside the above folders as given below:
- main.dart
import 'package:flutter/material.dart';
import 'pages/home_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Quill Demo',
theme: ThemeData(
// This is the theme of your application.
//
// Try running your application with "flutter run". You'll see the
// application has a blue toolbar. Then, without quitting the app, try
// changing the primarySwatch below to Colors.green and then invoke
// "hot reload" (press "r" in the console where you ran "flutter run",
// or simply save your changes to "hot reload" in a Flutter IDE).
// Notice that the counter didn't reset back to zero; the application
// is not restarted.
primarySwatch: Colors.blue,
// This makes the visual density adapt to the platform that you run
// the app on. For desktop platforms, the controls will be smaller and
// closer together (more dense) than on mobile platforms.
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomePage(),
);
}
}
- pages / home_page.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:filesystem_picker/filesystem_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:tuple/tuple.dart';
import '../universal_ui/universal_ui.dart';
import 'read_only_page.dart';
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
QuillController? _controller;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_loadFromAssets();
}
Future<void> _loadFromAssets() async {
try {
final result = await rootBundle.loadString('assets/sample_data.json');
final doc = Document.fromJson(jsonDecode(result));
setState(() {
_controller = QuillController(
document: doc, selection: const TextSelection.collapsed(offset: 0));
});
} catch (error) {
final doc = Document()..insert(0, 'Empty asset');
setState(() {
_controller = QuillController(
document: doc, selection: const TextSelection.collapsed(offset: 0));
});
}
}
@override
Widget build(BuildContext context) {
if (_controller == null) {
return const Scaffold(body: Center(child: Text('Loading...')));
}
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.grey.shade800,
elevation: 0,
centerTitle: false,
title: const Text(
'Flutter Quill',
),
actions: [],
),
drawer: Container(
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.7),
color: Colors.grey.shade800,
child: _buildMenuBar(context),
),
body: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) {
if (event.data.isControlPressed && event.character == 'b') {
if (_controller!
.getSelectionStyle()
.attributes
.keys
.contains('bold')) {
_controller!
.formatSelection(Attribute.clone(Attribute.bold, null));
} else {
_controller!.formatSelection(Attribute.bold);
}
}
},
child: _buildWelcomeEditor(context),
),
);
}
Widget _buildWelcomeEditor(BuildContext context) {
var quillEditor = QuillEditor(
controller: _controller!,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: false,
readOnly: false,
placeholder: 'Add content',
expands: false,
padding: EdgeInsets.zero,
customStyles: DefaultStyles(
h1: DefaultTextBlockStyle(
const TextStyle(
fontSize: 32,
color: Colors.black,
height: 1.15,
fontWeight: FontWeight.w300,
),
const Tuple2(16, 0),
const Tuple2(0, 0),
null),
sizeSmall: const TextStyle(fontSize: 9),
));
if (kIsWeb) {
quillEditor = QuillEditor(
controller: _controller!,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: false,
readOnly: false,
placeholder: 'Add content',
expands: false,
padding: EdgeInsets.zero,
customStyles: DefaultStyles(
h1: DefaultTextBlockStyle(
const TextStyle(
fontSize: 32,
color: Colors.black,
height: 1.15,
fontWeight: FontWeight.w300,
),
const Tuple2(16, 0),
const Tuple2(0, 0),
null),
sizeSmall: const TextStyle(fontSize: 9),
),
embedBuilder: defaultEmbedBuilderWeb);
}
var toolbar = QuillToolbar.basic(
controller: _controller!,
// provide a callback to enable picking images from device.
// if omit, "image" button only allows adding images from url.
// same goes for videos.
onImagePickCallback: _onImagePickCallback,
onVideoPickCallback: _onVideoPickCallback,
// uncomment to provide a custom "pick from" dialog.
// mediaPickSettingSelector: _selectMediaPickSetting,
showAlignmentButtons: true,
);
if (kIsWeb) {
toolbar = QuillToolbar.basic(
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
webImagePickImpl: _webImagePickImpl,
showAlignmentButtons: true,
);
}
if (_isDesktop()) {
toolbar = QuillToolbar.basic(
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
filePickImpl: openFileSystemPickerForDesktop,
showAlignmentButtons: true,
);
}
return SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
flex: 15,
child: Container(
color: Colors.white,
padding: const EdgeInsets.only(left: 16, right: 16),
child: quillEditor,
),
),
kIsWeb
? Expanded(
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: toolbar,
))
: Container(child: toolbar)
],
),
);
}
bool _isDesktop() => !kIsWeb && !Platform.isAndroid && !Platform.isIOS;
Future<String?> openFileSystemPickerForDesktop(BuildContext context) async {
return await FilesystemPicker.open(
context: context,
rootDirectory: await getApplicationDocumentsDirectory(),
fsType: FilesystemType.file,
fileTileSelectMode: FileTileSelectMode.wholeTile,
);
}
// 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 {
// 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();
}
Future<String?> _webImagePickImpl(
OnImagePickCallback onImagePickCallback) async {
final result = await FilePicker.platform.pickFiles();
if (result == null) {
return null;
}
// Take first, because we don't allow picking multiple files.
final fileName = result.files.first.name;
final file = File(fileName);
return onImagePickCallback(file);
}
// Renders the video picked by imagePicker from local file storage
// You can also upload the picked video to any server (eg : AWS s3
// or Firebase) and then return the uploaded video URL.
Future<String> _onVideoPickCallback(File file) async {
// 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();
}
Future<MediaPickSetting?> _selectMediaPickSetting(BuildContext context) =>
showDialog<MediaPickSetting>(
context: context,
builder: (ctx) => AlertDialog(
contentPadding: EdgeInsets.zero,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
icon: const Icon(Icons.collections),
label: const Text('Gallery'),
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Gallery),
),
TextButton.icon(
icon: const Icon(Icons.link),
label: const Text('Link'),
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Link),
)
],
),
),
);
Widget _buildMenuBar(BuildContext context) {
final size = MediaQuery.of(context).size;
const itemStyle = TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Divider(
thickness: 2,
color: Colors.white,
indent: size.width * 0.1,
endIndent: size.width * 0.1,
),
ListTile(
title: const Center(child: Text('Read only demo', style: itemStyle)),
dense: true,
visualDensity: VisualDensity.compact,
onTap: _readOnly,
),
Divider(
thickness: 2,
color: Colors.white,
indent: size.width * 0.1,
endIndent: size.width * 0.1,
),
],
);
}
void _readOnly() {
Navigator.push(
super.context,
MaterialPageRoute(
builder: (context) => ReadOnlyPage(),
),
);
}
}
- pages / read_only_page.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import '../universal_ui/universal_ui.dart';
import '../widgets/demo_scaffold.dart';
class ReadOnlyPage extends StatefulWidget {
@override
_ReadOnlyPageState createState() => _ReadOnlyPageState();
}
class _ReadOnlyPageState extends State<ReadOnlyPage> {
final FocusNode _focusNode = FocusNode();
bool _edit = false;
@override
Widget build(BuildContext context) {
return DemoScaffold(
documentFilename: 'sample_data.json',
builder: _buildContent,
showToolbar: _edit == true,
floatingActionButton: FloatingActionButton.extended(
label: Text(_edit == true ? 'Done' : 'Edit'),
onPressed: _toggleEdit,
icon: Icon(_edit == true ? Icons.check : Icons.edit)),
);
}
Widget _buildContent(BuildContext context, QuillController? controller) {
var quillEditor = QuillEditor(
controller: controller!,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: true,
readOnly: !_edit,
expands: false,
padding: EdgeInsets.zero,
);
if (kIsWeb) {
quillEditor = QuillEditor(
controller: controller,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: true,
readOnly: !_edit,
expands: false,
padding: EdgeInsets.zero,
embedBuilder: defaultEmbedBuilderWeb);
}
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade200),
),
child: quillEditor,
),
);
}
void _toggleEdit() {
setState(() {
_edit = !_edit;
});
}
}
- universal_ui / fake_ui.dart
class PlatformViewRegistry {
static void registerViewFactory(String viewId, dynamic cb) {}
}
- universal_ui / real_ui.dart
import 'dart:ui' as ui;
class PlatformViewRegistry {
static void registerViewFactory(String viewId, dynamic cb) {
ui.platformViewRegistry.registerViewFactory(viewId, cb);
}
}
- universal_ui / universal_ui.dart
library universal_ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:universal_html/html.dart' as html;
import '../widgets/responsive_widget.dart';
import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance;
class PlatformViewRegistryFix {
void registerViewFactory(dynamic x, dynamic y) {
if (kIsWeb) {
ui_instance.PlatformViewRegistry.registerViewFactory(
x,
y,
);
}
}
}
class UniversalUI {
PlatformViewRegistryFix platformViewRegistry = PlatformViewRegistryFix();
}
var ui = UniversalUI();
Widget defaultEmbedBuilderWeb(BuildContext context, Embed node, bool readOnly) {
switch (node.value.type) {
case 'image':
final String imageUrl = node.value.data;
final size = MediaQuery.of(context).size;
UniversalUI().platformViewRegistry.registerViewFactory(
imageUrl, (viewId) => html.ImageElement()..src = imageUrl);
return Padding(
padding: EdgeInsets.only(
right: ResponsiveWidget.isMediumScreen(context)
? size.width * 0.5
: (ResponsiveWidget.isLargeScreen(context))
? size.width * 0.75
: size.width * 0.2,
),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.45,
child: HtmlElementView(
viewType: imageUrl,
),
),
);
default:
throw UnimplementedError(
'Embeddable type "${node.value.type}" is not supported by default '
'embed builder of QuillEditor. You must pass your own builder function '
'to embedBuilder property of QuillEditor or QuillField widgets.',
);
}
}
- widgets / demo_scaffold.dart
import 'dart:convert';
import 'dart:io';
import 'package:filesystem_picker/filesystem_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:path_provider/path_provider.dart';
typedef DemoContentBuilder = Widget Function(
BuildContext context, QuillController? controller);
// Common scaffold for all examples.
class DemoScaffold extends StatefulWidget {
const DemoScaffold({
required this.documentFilename,
required this.builder,
this.actions,
this.showToolbar = true,
this.floatingActionButton,
Key? key,
}) : super(key: key);
/// Filename of the document to load into the editor.
final String documentFilename;
final DemoContentBuilder builder;
final List<Widget>? actions;
final Widget? floatingActionButton;
final bool showToolbar;
@override
_DemoScaffoldState createState() => _DemoScaffoldState();
}
class _DemoScaffoldState extends State<DemoScaffold> {
final _scaffoldKey = GlobalKey<ScaffoldState>();
QuillController? _controller;
bool _loading = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller == null && !_loading) {
_loading = true;
_loadFromAssets();
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
Future<void> _loadFromAssets() async {
try {
final result =
await rootBundle.loadString('assets/${widget.documentFilename}');
final doc = Document.fromJson(jsonDecode(result));
setState(() {
_controller = QuillController(
document: doc, selection: const TextSelection.collapsed(offset: 0));
_loading = false;
});
} catch (error) {
final doc = Document()..insert(0, 'Empty asset');
setState(() {
_controller = QuillController(
document: doc, selection: const TextSelection.collapsed(offset: 0));
_loading = false;
});
}
}
Future<String?> openFileSystemPickerForDesktop(BuildContext context) async {
return await FilesystemPicker.open(
context: context,
rootDirectory: await getApplicationDocumentsDirectory(),
fsType: FilesystemType.file,
fileTileSelectMode: FileTileSelectMode.wholeTile,
);
}
@override
Widget build(BuildContext context) {
final actions = widget.actions ?? <Widget>[];
var toolbar = QuillToolbar.basic(controller: _controller!);
if (_isDesktop()) {
toolbar = QuillToolbar.basic(
controller: _controller!,
filePickImpl: openFileSystemPickerForDesktop);
}
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).canvasColor,
centerTitle: false,
titleSpacing: 0,
leading: IconButton(
icon: Icon(
Icons.chevron_left,
color: Colors.grey.shade800,
size: 18,
),
onPressed: () => Navigator.pop(context),
),
title: _loading || !widget.showToolbar ? null : toolbar,
actions: actions,
),
floatingActionButton: widget.floatingActionButton,
body: _loading
? const Center(child: Text('Loading...'))
: widget.builder(context, _controller),
);
}
bool _isDesktop() => !kIsWeb && !Platform.isAndroid && !Platform.isIOS;
}
- widgets / responsive_widget.dart
import 'package:flutter/material.dart';
class ResponsiveWidget extends StatelessWidget {
const ResponsiveWidget({
required this.largeScreen,
this.mediumScreen,
this.smallScreen,
Key? key,
}) : super(key: key);
final Widget largeScreen;
final Widget? mediumScreen;
final Widget? smallScreen;
static bool isSmallScreen(BuildContext context) {
return MediaQuery.of(context).size.width < 800;
}
static bool isLargeScreen(BuildContext context) {
return MediaQuery.of(context).size.width > 1200;
}
static bool isMediumScreen(BuildContext context) {
return MediaQuery.of(context).size.width >= 800 &&
MediaQuery.of(context).size.width <= 1200;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 1200) {
return largeScreen;
} else if (constraints.maxWidth <= 1200 &&
constraints.maxWidth >= 800) {
return mediumScreen ?? largeScreen;
} else {
return smallScreen ?? largeScreen;
}
},
);
}
}
GitHub
Source Code: A rich text editor for Flutter, flutter_quill.