Merge pull request #1429 from LucasXu0/refactor_appflowy_editor_example

Refactor appflowy editor example
This commit is contained in:
Lucas.Xu 2022-11-09 16:48:57 +08:00 committed by GitHub
commit 65f677b277
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 422 additions and 42217 deletions

View File

@ -1,5 +1,7 @@
## 0.0.7
* Refactor theme customizer, and support dark mode.
* Support export and import markdown.
* Refactor example project.
* Fix some bugs.
## 0.0.6

View File

@ -54,11 +54,9 @@ flutter pub get
Start by creating a new empty AppFlowyEditor object.
```dart
final editorStyle = EditorStyle.defaultStyle();
final editorState = EditorState.empty(); // an empty state
final editor = AppFlowyEditor(
editorState: editorState,
editorStyle: editorStyle,
);
```
@ -66,11 +64,9 @@ You can also create an editor from a JSON object in order to configure your init
```dart
final json = ...;
final editorStyle = EditorStyle.defaultStyle();
final editorState = EditorState(Document.fromJson(data));
final editor = AppFlowyEditor(
editorState: editorState,
editorStyle: editorStyle,
);
```

View File

@ -293,7 +293,6 @@ final editorState = EditorState(
);
return AppFlowyEditor(
editorState: editorState,
editorStyle: EditorStyle.defaultStyle(),
shortcutEvents: const [],
customBuilders: {
'network_image': NetworkImageNodeWidgetBuilder(),

View File

@ -10,7 +10,8 @@
},
"delta": [
{ "insert": "👋 " },
{ "insert": "Welcome to ", "attributes": { "bold": true } },
{ "insert": "Welcome to", "attributes": { "bold": true } },
{ "insert": " " },
{
"insert": "AppFlowy Editor",
"attributes": {
@ -25,7 +26,8 @@
{
"type": "text",
"delta": [
{ "insert": "AppFlowy Editor is a " },
{ "insert": "AppFlowy Editor is a" },
{ "insert": " " },
{ "insert": "highly customizable", "attributes": { "bold": true } },
{ "insert": " " },
{ "insert": "rich-text editor", "attributes": { "italic": true } },

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@ -0,0 +1,328 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/simple_editor.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:universal_html/html.dart' as html;
enum ExportFileType {
json,
markdown,
html,
}
extension on ExportFileType {
String get extension {
switch (this) {
case ExportFileType.json:
return 'json';
case ExportFileType.markdown:
return 'md';
case ExportFileType.html:
return 'html';
}
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _scaffoldKey = GlobalKey<ScaffoldState>();
late WidgetBuilder _widgetBuilder;
late EditorState _editorState;
late Future<String> _jsonString;
ThemeData _themeData = ThemeData.light().copyWith(
extensions: [
...lightEditorStyleExtension,
...lightPlguinStyleExtension,
],
);
@override
void initState() {
super.initState();
_jsonString = rootBundle.loadString('assets/example.json');
_widgetBuilder = (context) => SimpleEditor(
jsonString: _jsonString,
themeData: _themeData,
onEditorStateChange: (editorState) {
_editorState = editorState;
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
extendBodyBehindAppBar: true,
drawer: _buildDrawer(context),
body: _buildBody(context),
floatingActionButton: _buildFloatingActionButton(context),
);
}
Widget _buildDrawer(BuildContext context) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
padding: EdgeInsets.zero,
margin: EdgeInsets.zero,
child: Image.asset(
'assets/images/icon.png',
fit: BoxFit.fill,
),
),
// AppFlowy Editor Demo
_buildSeparator(context, 'AppFlowy Editor Demo'),
_buildListTile(context, 'With Example.json', () {
final jsonString = rootBundle.loadString('assets/example.json');
_loadEditor(context, jsonString);
}),
_buildListTile(context, 'With Empty Document', () {
final jsonString = Future<String>.value(
jsonEncode(EditorState.empty().document.toJson()).toString(),
);
_loadEditor(context, jsonString);
}),
// Encoder Demo
_buildSeparator(context, 'Encoder Demo'),
_buildListTile(context, 'Export To JSON', () {
_exportFile(_editorState, ExportFileType.json);
}),
_buildListTile(context, 'Export to Markdown', () {
_exportFile(_editorState, ExportFileType.markdown);
}),
// Decoder Demo
_buildSeparator(context, 'Decoder Demo'),
_buildListTile(context, 'Import From JSON', () {
_importFile(ExportFileType.json);
}),
_buildListTile(context, 'Import From Markdown', () {
_importFile(ExportFileType.markdown);
}),
// Theme Demo
_buildSeparator(context, 'Theme Demo'),
_buildListTile(context, 'Bulit In Dark Mode', () {
_jsonString = Future<String>.value(
jsonEncode(_editorState.document.toJson()).toString(),
);
setState(() {
_themeData = ThemeData.dark().copyWith(
extensions: [
...darkEditorStyleExtension,
...darkPlguinStyleExtension,
],
);
});
}),
_buildListTile(context, 'Custom Theme', () {
_jsonString = Future<String>.value(
jsonEncode(_editorState.document.toJson()).toString(),
);
setState(() {
_themeData = _customizeEditorTheme(context);
});
}),
],
),
);
}
Widget _buildBody(BuildContext context) {
return _widgetBuilder(context);
}
Widget _buildListTile(
BuildContext context,
String text,
VoidCallback? onTap,
) {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 16),
title: Text(
text,
style: const TextStyle(
color: Colors.blue,
fontSize: 14,
),
),
onTap: () {
Navigator.pop(context);
onTap?.call();
},
);
}
Widget _buildSeparator(BuildContext context, String text) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 4),
child: Text(
text,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildFloatingActionButton(BuildContext context) {
return FloatingActionButton(
onPressed: () {
_scaffoldKey.currentState?.openDrawer();
},
child: const Icon(Icons.menu),
);
}
void _loadEditor(BuildContext context, Future<String> jsonString) {
_jsonString = jsonString;
setState(
() {
_widgetBuilder = (context) => SimpleEditor(
jsonString: _jsonString,
themeData: _themeData,
onEditorStateChange: (editorState) {
_editorState = editorState;
},
);
},
);
}
void _exportFile(
EditorState editorState,
ExportFileType fileType,
) async {
var result = '';
switch (fileType) {
case ExportFileType.json:
result = jsonEncode(editorState.document.toJson());
break;
case ExportFileType.markdown:
result = documentToMarkdown(editorState.document);
break;
case ExportFileType.html:
throw UnimplementedError();
}
if (!kIsWeb) {
final path = await FilePicker.platform.saveFile(
fileName: 'document.${fileType.extension}',
);
if (path != null) {
await File(path).writeAsString(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This document is saved to the $path'),
),
);
}
}
} else {
final blob = html.Blob([result], 'text/plain', 'native');
html.AnchorElement(
href: html.Url.createObjectUrlFromBlob(blob).toString(),
)
..setAttribute('download', 'document.${fileType.extension}')
..click();
}
}
void _importFile(ExportFileType fileType) async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
allowedExtensions: [fileType.extension],
type: FileType.custom,
);
var plainText = '';
if (!kIsWeb) {
final path = result?.files.single.path;
if (path == null) {
return;
}
plainText = await File(path).readAsString();
} else {
final bytes = result?.files.first.bytes;
if (bytes == null) {
return;
}
plainText = const Utf8Decoder().convert(bytes);
}
var jsonString = '';
switch (fileType) {
case ExportFileType.json:
jsonString = jsonEncode(plainText);
break;
case ExportFileType.markdown:
jsonString = jsonEncode(markdownToDocument(plainText).toJson());
break;
case ExportFileType.html:
throw UnimplementedError();
}
if (mounted) {
_loadEditor(context, Future<String>.value(jsonString));
}
}
ThemeData _customizeEditorTheme(BuildContext context) {
final dark = EditorStyle.dark;
final editorStyle = dark.copyWith(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 150),
cursorColor: Colors.blue.shade600,
selectionColor: Colors.yellow.shade600.withOpacity(0.5),
textStyle: GoogleFonts.poppins().copyWith(
fontSize: 14,
color: Colors.grey,
),
placeholderTextStyle: GoogleFonts.poppins().copyWith(
fontSize: 14,
color: Colors.grey.shade500,
),
code: dark.code?.copyWith(
backgroundColor: Colors.lightBlue.shade200,
fontStyle: FontStyle.italic,
),
highlightColorHex: '0x60FF0000', // red
);
final quote = QuotedTextPluginStyle.dark.copyWith(
textStyle: (_, __) => GoogleFonts.poppins().copyWith(
fontSize: 14,
color: Colors.blue.shade400,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w700,
),
);
return Theme.of(context).copyWith(extensions: [
editorStyle,
...darkPlguinStyleExtension,
quote,
]);
}
}

View File

@ -1,23 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'package:example/plugin/editor_theme.dart';
import 'package:flutter/foundation.dart';
import 'package:example/home_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:example/plugin/code_block_node_widget.dart';
import 'package:example/plugin/horizontal_rule_node_widget.dart';
import 'package:example/plugin/tex_block_node_widget.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html;
import 'package:appflowy_editor/appflowy_editor.dart';
import 'expandable_floating_action_button.dart';
void main() {
runApp(const MyApp());
}
@ -50,201 +37,8 @@ class MyHomePage extends StatefulWidget {
}
class _MyHomePageState extends State<MyHomePage> {
int _pageIndex = 0;
EditorState? _editorState;
bool darkMode = false;
Future<String>? _jsonString;
ThemeData? _editorThemeData;
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
body: _buildEditor(context),
floatingActionButton: _buildExpandableFab(),
);
}
Widget _buildEditor(BuildContext context) {
if (_jsonString != null) {
return _buildEditorWithJsonString(_jsonString!);
}
if (_pageIndex == 0) {
return _buildEditorWithJsonString(
rootBundle.loadString('assets/example.json'),
);
} else if (_pageIndex == 1) {
return _buildEditorWithJsonString(
Future.value(
jsonEncode(EditorState.empty().document.toJson()),
),
);
}
throw UnimplementedError();
}
Widget _buildEditorWithJsonString(Future<String> jsonString) {
return FutureBuilder<String>(
future: jsonString,
builder: (_, snapshot) {
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
_editorState ??= EditorState(
document: Document.fromJson(
Map<String, Object>.from(
json.decode(snapshot.data!),
),
),
);
_editorState!.logConfiguration
..level = LogLevel.all
..handler = (message) {
debugPrint(message);
};
_editorState!.transactionStream.listen((event) {
debugPrint('Transaction: ${event.toJson()}');
});
_editorThemeData ??= Theme.of(context).copyWith(extensions: [
if (darkMode) ...darkEditorStyleExtension,
if (darkMode) ...darkPlguinStyleExtension,
if (!darkMode) ...lightEditorStyleExtension,
if (!darkMode) ...lightPlguinStyleExtension,
]);
return Container(
color: darkMode ? Colors.black : Colors.white,
width: MediaQuery.of(context).size.width,
child: AppFlowyEditor(
editorState: _editorState!,
editable: true,
autoFocus: _editorState!.document.isEmpty,
themeData: _editorThemeData,
customBuilders: {
'text/code_block': CodeBlockNodeWidgetBuilder(),
'tex': TeXBlockNodeWidgetBuidler(),
'horizontal_rule': HorizontalRuleWidgetBuilder(),
},
shortcutEvents: [
enterInCodeBlock,
ignoreKeysInCodeBlock,
insertHorizontalRule,
],
selectionMenuItems: [
codeBlockMenuItem,
teXBlockMenuItem,
horizontalRuleMenuItem,
],
),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
Widget _buildExpandableFab() {
return ExpandableFab(
distance: 112.0,
children: [
ActionButton(
icon: const Icon(Icons.abc),
onPressed: () => _switchToPage(0),
),
ActionButton(
icon: const Icon(Icons.abc),
onPressed: () => _switchToPage(1),
),
ActionButton(
icon: const Icon(Icons.print),
onPressed: () => _exportDocument(_editorState!),
),
ActionButton(
icon: const Icon(Icons.import_export),
onPressed: () async => await _importDocument(),
),
ActionButton(
icon: const Icon(Icons.dark_mode),
onPressed: () {
setState(() {
darkMode = !darkMode;
});
},
),
ActionButton(
icon: const Icon(Icons.color_lens),
onPressed: () {
setState(() {
_editorThemeData = customizeEditorTheme(context);
darkMode = true;
});
},
),
],
);
}
void _exportDocument(EditorState editorState) async {
final document = editorState.document.toJson();
final json = jsonEncode(document);
if (kIsWeb) {
final blob = html.Blob([json], 'text/plain', 'native');
html.AnchorElement(
href: html.Url.createObjectUrlFromBlob(blob).toString(),
)
..setAttribute('download', 'editor.json')
..click();
} else {
final directory = await getTemporaryDirectory();
final path = directory.path;
final file = File('$path/editor.json');
await file.writeAsString(json);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('The document is saved to the ${file.path}'),
),
);
}
}
}
Future<void> _importDocument() async {
if (kIsWeb) {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
allowedExtensions: ['json'],
type: FileType.custom,
);
final bytes = result?.files.first.bytes;
if (bytes != null) {
final jsonString = const Utf8Decoder().convert(bytes);
setState(() {
_editorState = null;
_jsonString = Future.value(jsonString);
});
}
} else {
final directory = await getTemporaryDirectory();
final path = '${directory.path}/editor.json';
final file = File(path);
setState(() {
_editorState = null;
_jsonString = file.readAsString();
});
}
}
void _switchToPage(int pageIndex) {
if (pageIndex != _pageIndex) {
setState(() {
_editorThemeData = null;
_editorState = null;
_pageIndex = pageIndex;
});
}
return const HomePage();
}
}

View File

@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class SimpleEditor extends StatelessWidget {
const SimpleEditor({
super.key,
required this.jsonString,
required this.themeData,
required this.onEditorStateChange,
});
final Future<String> jsonString;
final ThemeData themeData;
final void Function(EditorState editorState) onEditorStateChange;
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: jsonString,
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
final editorState = EditorState(
document: Document.fromJson(
Map<String, Object>.from(
json.decode(snapshot.data!),
),
),
);
onEditorStateChange(editorState);
return AppFlowyEditor(
editorState: editorState,
themeData: themeData,
autoFocus: editorState.document.isEmpty,
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
}

View File

@ -70,8 +70,7 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- example.json
- big_document.json
# - images/a_dot_ham.jpeg
- assets/images/icon.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware

View File

@ -115,6 +115,10 @@ class Document {
return true;
}
if (root.children.length > 1) {
return false;
}
final node = root.children.first;
if (node is TextNode &&
(node.delta.isEmpty || node.delta.toPlainText().isEmpty)) {

View File

@ -11,6 +11,7 @@ Iterable<ThemeExtension<dynamic>> get darkEditorStyleExtension => [
class EditorStyle extends ThemeExtension<EditorStyle> {
// Editor styles
final EdgeInsets? padding;
final Color? backgroundColor;
final Color? cursorColor;
final Color? selectionColor;
@ -39,6 +40,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
EditorStyle({
required this.padding,
required this.backgroundColor,
required this.cursorColor,
required this.selectionColor,
required this.selectionMenuBackgroundColor,
@ -63,6 +65,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
@override
EditorStyle copyWith({
EdgeInsets? padding,
Color? backgroundColor,
Color? cursorColor,
Color? selectionColor,
Color? selectionMenuBackgroundColor,
@ -84,6 +87,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
}) {
return EditorStyle(
padding: padding ?? this.padding,
backgroundColor: backgroundColor ?? this.backgroundColor,
cursorColor: cursorColor ?? this.cursorColor,
selectionColor: selectionColor ?? this.selectionColor,
selectionMenuBackgroundColor:
@ -120,6 +124,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
}
return EditorStyle(
padding: EdgeInsets.lerp(padding, other.padding, t),
backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t),
cursorColor: Color.lerp(cursorColor, other.cursorColor, t),
textPadding: EdgeInsets.lerp(textPadding, other.textPadding, t),
selectionColor: Color.lerp(selectionColor, other.selectionColor, t),
@ -155,6 +160,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
static final light = EditorStyle(
padding: const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0),
backgroundColor: Colors.white,
cursorColor: const Color(0xFF00BCF0),
selectionColor: const Color.fromARGB(53, 111, 201, 231),
selectionMenuBackgroundColor: const Color(0xFFFFFFFF),
@ -184,6 +190,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
);
static final dark = light.copyWith(
backgroundColor: Colors.black,
textStyle: const TextStyle(fontSize: 16.0, color: Colors.white),
placeholderTextStyle: TextStyle(
fontSize: 16.0,

View File

@ -119,7 +119,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
data: widget.themeData,
child: AppFlowyScroll(
key: editorState.service.scrollServiceKey,
child: Padding(
child: Container(
color: editorStyle.backgroundColor,
padding: editorStyle.padding!,
child: AppFlowySelection(
key: editorState.service.selectionServiceKey,

View File

@ -252,6 +252,31 @@ Delta _lineContentToDelta(String lineContent) {
return delta;
}
void _pasteMarkdown(EditorState editorState, String markdown) {
final selection =
editorState.service.selectionService.currentSelection.value?.normalized;
if (selection == null) {
return;
}
final lines = markdown.split('\n');
if (lines.length == 1) {
_pasteSingleLine(editorState, selection, lines[0]);
return;
}
var path = selection.end.path.next;
final node = editorState.document.nodeAtPath(selection.end.path);
if (node is TextNode && node.toPlainText().isEmpty) {
path = selection.end.path;
}
final document = markdownToDocument(markdown);
final transaction = editorState.transaction;
transaction.insertNodes(path, document.root.children);
editorState.apply(transaction);
}
void _handlePastePlainText(EditorState editorState, String plainText) {
final selection = editorState.cursorSelection?.normalized;
if (selection == null) {
@ -269,45 +294,7 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
// single line
_pasteSingleLine(editorState, selection, lines.first);
} else {
final firstLine = lines[0];
final beginOffset = selection.end.offset;
final remains = lines.sublist(1);
final path = [...selection.end.path];
if (path.isEmpty) {
return;
}
final node =
editorState.document.nodeAtPath(selection.end.path)! as TextNode;
final insertedLineSuffix = node.delta.slice(beginOffset);
path[path.length - 1]++;
final tb = editorState.transaction;
final List<TextNode> nodes =
remains.map((e) => TextNode(delta: _lineContentToDelta(e))).toList();
final afterSelection =
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
// append remain text to the last line
if (nodes.isNotEmpty) {
final last = nodes.last;
nodes[nodes.length - 1] =
TextNode(delta: last.delta..addAll(insertedLineSuffix));
}
// insert first line
tb.updateText(
node,
Delta()
..retain(beginOffset)
..insert(firstLine)
..delete(node.delta.length - beginOffset));
// insert remains
tb.insertNodes(path, nodes);
tb.afterSelection = afterSelection;
editorState.apply(tb);
_pasteMarkdown(editorState, plainText);
}
}