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 { 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 _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: [ 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 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 _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 _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 _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 _selectMediaPickSetting(BuildContext context) => showDialog( 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 _selectCameraPickSetting(BuildContext context) => showDialog( 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 _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 _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 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)); }