Rich text editor for Flutter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

537 lines
16 KiB

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/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 '../universal_ui/universal_ui.dart';
import 'read_only_page.dart';
enum _SelectionType {
none,
word,
// line,
}
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
QuillController? _controller;
final FocusNode _focusNode = FocusNode();
Timer? _selectAllTimer;
_SelectionType _selectionType = _SelectionType.none;
@override
void dispose() {
_selectAllTimer?.cancel();
super.dispose();
}
@override
void initState() {
super.initState();
_loadFromAssets();
}
Future<void> _loadFromAssets() async {
try {
final result = await rootBundle.loadString(isDesktop()
? 'assets/sample_data_nomedia.json'
: '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: [
IconButton(
onPressed: () => _addEditNote(context),
icon: const Icon(Icons.note_add),
),
],
),
drawer: Container(
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.7),
color: Colors.grey.shade800,
child: _buildMenuBar(context),
),
body: _buildWelcomeEditor(context),
);
}
bool _onTripleClickSelection() {
final controller = _controller!;
_selectAllTimer?.cancel();
_selectAllTimer = null;
// If you want to select all text after paragraph, uncomment this line
// if (_selectionType == _SelectionType.line) {
// final selection = TextSelection(
// baseOffset: 0,
// extentOffset: controller.document.length,
// );
// controller.updateSelection(selection, ChangeSource.REMOTE);
// _selectionType = _SelectionType.none;
// return true;
// }
if (controller.selection.isCollapsed) {
_selectionType = _SelectionType.none;
}
if (_selectionType == _SelectionType.none) {
_selectionType = _SelectionType.word;
_startTripleClickTimer();
return false;
}
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,
);
controller.updateSelection(selection, ChangeSource.REMOTE);
// _selectionType = _SelectionType.line;
_selectionType = _SelectionType.none;
_startTripleClickTimer();
return true;
}
return false;
}
void _startTripleClickTimer() {
_selectAllTimer = Timer(const Duration(milliseconds: 900), () {
_selectionType = _SelectionType.none;
});
}
Widget _buildWelcomeEditor(BuildContext context) {
Widget quillEditor = MouseRegion(
cursor: SystemMouseCursors.text,
child: QuillEditor(
controller: _controller!,
scrollController: ScrollController(),
scrollable: true,
focusNode: _focusNode,
autoFocus: false,
readOnly: false,
placeholder: 'Add content',
enableSelectionToolbar: isMobile(),
expands: false,
padding: EdgeInsets.zero,
onImagePaste: _onImagePaste,
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: [
...FlutterQuillEmbeds.builders(),
NotesEmbedBuilder(addEditNote: _addEditNote)
],
),
);
if (kIsWeb) {
quillEditor = MouseRegion(
cursor: SystemMouseCursors.text,
child: 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,
NotesEmbedBuilder(addEditNote: _addEditNote),
]),
);
}
var toolbar = QuillToolbar.basic(
controller: _controller!,
embedButtons: FlutterQuillEmbeds.buttons(
// 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,
// uncomment to provide a custom "pick from" dialog.
// cameraPickSettingSelector: _selectCameraPickSetting,
),
showAlignmentButtons: true,
afterButtonPressed: _focusNode.requestFocus,
);
if (kIsWeb) {
toolbar = QuillToolbar.basic(
controller: _controller!,
embedButtons: FlutterQuillEmbeds.buttons(
onImagePickCallback: _onImagePickCallback,
webImagePickImpl: _webImagePickImpl,
),
showAlignmentButtons: true,
afterButtonPressed: _focusNode.requestFocus,
);
}
if (_isDesktop()) {
toolbar = QuillToolbar.basic(
controller: _controller!,
embedButtons: FlutterQuillEmbeds.buttons(
onImagePickCallback: _onImagePickCallback,
filePickImpl: openFileSystemPickerForDesktop,
),
showAlignmentButtons: true,
afterButtonPressed: _focusNode.requestFocus,
);
}
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();
}
// ignore: unused_element
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),
)
],
),
),
);
// ignore: unused_element
Future<MediaPickSetting?> _selectCameraPickSetting(BuildContext context) =>
showDialog<MediaPickSetting>(
context: context,
builder: (ctx) => AlertDialog(
contentPadding: EdgeInsets.zero,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
icon: const Icon(Icons.camera),
label: const Text('Capture a photo'),
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Camera),
),
TextButton.icon(
icon: const Icon(Icons.video_call),
label: const Text('Capture a video'),
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Video),
)
],
),
),
);
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.pop(super.context);
Navigator.push(
super.context,
MaterialPageRoute(
builder: (context) => ReadOnlyPage(),
),
);
}
Future<String> _onImagePaste(Uint8List imageBytes) async {
// Saves the image to applications directory
final appDocDir = await getApplicationDocumentsDirectory();
final file = await File(
'${appDocDir.path}/${basename('${DateTime.now().millisecondsSinceEpoch}.png')}')
.writeAsBytes(imageBytes, flush: true);
return file.path.toString();
}
Future<void> _addEditNote(BuildContext context, {Document? document}) async {
final isEditing = document != null;
final quillEditorController = QuillController(
document: document ?? Document(),
selection: const TextSelection.collapsed(offset: 0),
);
await showDialog(
context: context,
builder: (context) => AlertDialog(
titlePadding: const EdgeInsets.only(left: 16, top: 8),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${isEditing ? 'Edit' : 'Add'} note'),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
)
],
),
content: QuillEditor.basic(
controller: quillEditorController,
readOnly: false,
),
),
);
if (quillEditorController.document.isEmpty()) return;
final block = BlockEmbed.custom(
NotesBlockEmbed.fromDocument(quillEditorController.document),
);
final controller = _controller!;
final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index;
if (isEditing) {
final offset =
getEmbedNode(controller, controller.selection.start).offset;
controller.replaceText(
offset, 1, block, TextSelection.collapsed(offset: offset));
} else {
controller.replaceText(index, length, block, null);
}
}
}
class NotesEmbedBuilder extends EmbedBuilder {
NotesEmbedBuilder({required this.addEditNote});
Future<void> Function(BuildContext context, {Document? document}) addEditNote;
@override
String get key => 'notes';
@override
Widget build(
BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
bool inline,
TextStyle textStyle,
) {
final notes = NotesBlockEmbed(node.value.data).document;
return Material(
color: Colors.transparent,
child: ListTile(
title: Text(
notes.toPlainText().replaceAll('\n', ' '),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
leading: const Icon(Icons.notes),
onTap: () => addEditNote(context, document: notes),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: const BorderSide(color: Colors.grey),
),
),
);
}
}
class NotesBlockEmbed extends CustomBlockEmbed {
const NotesBlockEmbed(String value) : super(noteType, value);
static const String noteType = 'notes';
static NotesBlockEmbed fromDocument(Document document) =>
NotesBlockEmbed(jsonEncode(document.toDelta().toJson()));
Document get document => Document.fromJson(jsonDecode(data));
}