feat: customize href (#293)

This commit is contained in:
Lucas.Xu 2023-07-07 16:07:47 +08:00 • committed by GitHub
commit 46158d9e26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 766 additions and 1635 deletions

View file

@ -56,7 +56,7 @@ Start by creating a new empty AppFlowyEditor object.
```dart ```dart
final editorState = EditorState.blank(withInitialText: true); // with an empty paragraph final editorState = EditorState.blank(withInitialText: true); // with an empty paragraph
final editor = AppFlowyEditor.standard( final editor = AppFlowyEditor(
editorState: editorState, editorState: editorState,
); );
``` ```
@ -66,7 +66,7 @@ You can also create an editor from a JSON object in order to configure your init
```dart ```dart
final json = jsonDecode('YOUR INPUT JSON STRING'); final json = jsonDecode('YOUR INPUT JSON STRING');
final editorState = EditorState(document: Document.fromJson(json)); final editorState = EditorState(document: Document.fromJson(json));
final editor = AppFlowyEditor.standard( final editor = AppFlowyEditor(
editorState: editorState, editorState: editorState,
); );
``` ```
@ -90,6 +90,12 @@ flutter run
## Customizing Your Editor ## Customizing Your Editor
### Customizing theme
Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing theme](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/customizing.md#customizing-a-theme).
* See further examples of [how AppFlowy custom the theme](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart)
### Customizing Block Components ### Customizing Block Components
Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing components](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/customizing.md#customize-a-component). Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing components](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/documentation/customizing.md#customize-a-component).
@ -98,7 +104,7 @@ Below are some examples of component customizations:
* [Todo List Block Component](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart) demonstrates how to extend new styles based on existing rich text components * [Todo List Block Component](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart) demonstrates how to extend new styles based on existing rich text components
* [Divider Block Component](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/editor/block_component/divider_block_component/divider_block_component.dart) demonstrates how to extend a new block component and render it * [Divider Block Component](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/lib/src/editor/block_component/divider_block_component/divider_block_component.dart) demonstrates how to extend a new block component and render it
* See further examples of [Rich-Text Plugins](https://github.com/AppFlowy-IO/appflowy-editor/tree/main/lib/src/editor/block_component) * See further examples of [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart)
### Customizing Shortcut Events ### Customizing Shortcut Events

View file

@ -89,175 +89,7 @@ CharacterShortcutEvent underScoreToItalicEvent = CharacterShortcutEvent(
Check out the [complete code](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/example/lib/samples/underscore_to_italic.dart) file of this example. Check out the [complete code](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/example/lib/samples/underscore_to_italic.dart) file of this example.
## Customizing a Component ## Customizing a Theme
> ⚠️ Notes: The content below is outdated.
We will use a simple example to show how to quickly add a custom component.
In this example we will render an image from the network.
Let's start with a blank document:
```dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
alignment: Alignment.topCenter,
child: AppFlowyEditor(
editorState: EditorState.empty(),
shortcutEvents: const [],
customBuilders: const {},
),
),
);
}
```
Next, we will choose a unique string for your custom node's type.
We'll use `network_image` in this case. And we add `network_image_src` to the `attributes` to describe the link of the image.
```JSON
{
"type": "network_image",
"data": {
"network_image_src": "https://docs.flutter.dev/assets/images/dash/dash-fainting.gif"
}
}
```
Then, we create a class that inherits [NodeWidgetBuilder](../lib/src/service/render_plugin_service.dart). As shown in the autoprompt, we need to implement two functions:
1. one returns a widget
2. the other verifies the correctness of the [Node](../lib/src/core/document/node.dart).
```dart
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder {
@override
Widget build(NodeWidgetContext<Node> context) {
throw UnimplementedError();
}
@override
NodeValidator<Node> get nodeValidator => throw UnimplementedError();
}
```
Now, let's implement a simple image widget based on `Image`.
Note that the `State` object that is returned by the `Widget` must implement [Selectable](../lib/src/render/selection/selectable.dart) using the `with` keyword.
```dart
class _NetworkImageNodeWidget extends StatefulWidget {
const _NetworkImageNodeWidget({
Key? key,
required this.node,
}) : super(key: key);
final Node node;
@override
State<_NetworkImageNodeWidget> createState() =>
__NetworkImageNodeWidgetState();
}
class __NetworkImageNodeWidgetState extends State<_NetworkImageNodeWidget>
with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Widget build(BuildContext context) {
return Image.network(
widget.node.attributes['network_image_src'],
height: 200,
loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : const CircularProgressIndicator(),
);
}
@override
Position start() => Position(path: widget.node.path, offset: 0);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
List<Rect> getRectsInSelection(Selection selection) =>
[Offset.zero & _renderBox.size];
@override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 1,
);
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
}
```
Finally, we return `_NetworkImageNodeWidget` in the `build` function of `NetworkImageNodeWidgetBuilder`...
```dart
class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder {
@override
Widget build(NodeWidgetContext<Node> context) {
return _NetworkImageNodeWidget(
key: context.node.key,
node: context.node,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.type == 'network_image' &&
node.attributes['network_image_src'] is String;
};
}
```
... and register `NetworkImageNodeWidgetBuilder` in the `AppFlowyEditor`.
```dart
final editorState = EditorState(
document: StateTree.empty()
..insert(
[0],
[
TextNode.empty(),
Node.fromJson({
'type': 'network_image',
'attributes': {
'network_image_src':
'https://docs.flutter.dev/assets/images/dash/dash-fainting.gif'
}
})
],
),
);
return AppFlowyEditor(
editorState: editorState,
shortcutEvents: const [],
customBuilders: {
'network_image': NetworkImageNodeWidgetBuilder(),
},
);
```
![Whew!](./images/customize_a_component.gif)
Check out the [complete code](https://github.com/AppFlowy-IO/appflowy-editor/blob/main/example/lib/plugin/network_image_node_widget.dart) file of this example.
## Customizing a Theme (New Feature in 0.0.7)
We will use a simple example to illustrate how to quickly customize a theme. We will use a simple example to illustrate how to quickly customize a theme.
@ -270,9 +102,7 @@ Widget build(BuildContext context) {
body: Container( body: Container(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: AppFlowyEditor( child: AppFlowyEditor(
editorState: EditorState.empty(), editorState: EditorState.blank(),
shortcutEvents: const [],
customBuilders: const {},
), ),
), ),
); );
@ -283,44 +113,118 @@ At this point, the editor looks like ...
![Before](./images/customizing_a_theme_before.png) ![Before](./images/customizing_a_theme_before.png)
Next, we will customize the `EditorStyle`. Next, we will customize the `EditorStyle` and the block style with `BlockComponentConfiguration`.
```dart ```dart
ThemeData customizeEditorTheme(BuildContext context) { EditorStyle customizeEditorStyle() {
final dark = EditorStyle.dark; return EditorStyle(
final editorStyle = dark.copyWith( padding: PlatformExtension.isDesktopOrWeb
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 150), ? const EdgeInsets.only(left: 100, right: 100, top: 20)
cursorColor: Colors.red.shade600, : const EdgeInsets.symmetric(horizontal: 20),
selectionColor: Colors.yellow.shade600.withOpacity(0.5), cursorColor: Colors.green,
textStyle: GoogleFonts.poppins().copyWith( selectionColor: Colors.green,
fontSize: 14, textStyleConfiguration: TextStyleConfiguration(
text: const TextStyle(
fontSize: 18.0,
color: Colors.white54,
),
bold: const TextStyle(
fontWeight: FontWeight.w900,
),
href: TextStyle(
color: Colors.amber,
decoration: TextDecoration.combine(
[
TextDecoration.overline,
TextDecoration.underline,
],
),
),
code: const TextStyle(
fontSize: 14.0,
fontStyle: FontStyle.italic,
color: Colors.blue,
backgroundColor: Colors.black12,
),
),
textSpanDecorator: (context, node, index, text, textSpan) {
final attributes = text.attributes;
final href = attributes?[AppFlowyRichTextKeys.href];
if (href != null) {
return TextSpan(
text: text.text,
style: textSpan.style,
recognizer: TapGestureRecognizer()
..onTap = () {
debugPrint('onTap: $href');
},
);
}
return textSpan;
},
);
}
Map<String, BlockComponentBuilder> customBuilder() {
final configuration = BlockComponentConfiguration(
padding: (node) {
if (HeadingBlockKeys.type == node.type) {
return const EdgeInsets.symmetric(vertical: 30);
}
return const EdgeInsets.symmetric(vertical: 10);
},
textStyle: (node) {
if (HeadingBlockKeys.type == node.type) {
return const TextStyle(color: Colors.yellow);
}
return const TextStyle();
},
);
// customize heading block style
return {
...standardBlockComponentBuilderMap,
// heading block
HeadingBlockKeys.type: HeadingBlockComponentBuilder(
configuration: configuration,
),
// todo-list block
TodoListBlockKeys.type: TodoListBlockComponentBuilder(
configuration: configuration,
iconBuilder: (context, node) {
final checked = node.attributes[TodoListBlockKeys.checked] as bool;
return Icon(
checked ? Icons.check_box : Icons.check_box_outline_blank,
size: 20,
color: Colors.white, color: Colors.white,
),
placeholderTextStyle: GoogleFonts.poppins().copyWith(
fontSize: 14,
color: Colors.grey.shade400,
),
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,
), ),
// bulleted list block
BulletedListBlockKeys.type: BulletedListBlockComponentBuilder(
configuration: configuration,
iconBuilder: (context, node) {
return const Icon(
Icons.circle,
size: 20,
color: Colors.green,
); );
},
return Theme.of(context).copyWith(extensions: [ ),
editorStyle, // quote block
...darkPlguinStyleExtension, QuoteBlockKeys.type: QuoteBlockComponentBuilder(
quote, configuration: configuration,
]); iconBuilder: (context, node) {
return const EditorSvg(
width: 20,
height: 20,
padding: EdgeInsets.only(right: 5.0),
name: 'quote',
color: Colors.pink,
);
},
),
};
} }
``` ```
@ -333,10 +237,9 @@ Widget build(BuildContext context) {
body: Container( body: Container(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: AppFlowyEditor( child: AppFlowyEditor(
editorState: EditorState.empty(), editorState: EditorState.blank(),
themeData: customizeEditorTheme(context), editorStyle: customizeEditorStyle(),
shortcutEvents: const [], blockComponentBuilders: customBuilder(),
customBuilders: const {},
), ),
), ),
); );
@ -344,61 +247,3 @@ Widget build(BuildContext context) {
``` ```
![After](./images/customizing_a_theme_after.png) ![After](./images/customizing_a_theme_after.png)
### Note:
`themeData` has since been depreciated, and you should now use `textStyleConfiguration`. If you would like to use dark mode, the following code will set the text colour to white:
```dart
editorStyle: const EditorStyle.desktop().copyWith(
textStyleConfiguration: TextStyleConfiguration(
text: TextStyle(
color: Theme.of(context).primaryColorLight,
)
)
)
```
The above example of `customizeEditorTheme` would turn into the following, which is how AppFlowy customises its editor style:
```dart
EditorStyle desktop() {
final theme = Theme.of(context);
final fontSize = context
.read<DocumentAppearanceCubit>()
.state
.fontSize;
return EditorStyle.desktop(
padding: padding,
backgroundColor: theme.colorScheme.surface,
cursorColor: theme.colorScheme.primary,
textStyleConfiguration: TextStyleConfiguration(
text: TextStyle(
fontFamily: 'Poppins',
fontSize: fontSize,
color: theme.colorScheme.onBackground,
height: 1.5,
),
bold: const TextStyle(
fontFamily: 'Poppins-Bold',
fontWeight: FontWeight.w600,
),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
href: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
),
code: GoogleFonts.robotoMono(
textStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.normal,
color: Colors.red,
backgroundColor: theme.colorScheme.inverseSurface,
),
),
),
);
}
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 607 KiB

Before After
Before After

View file

@ -128,8 +128,8 @@
"attributes": { "attributes": {
"italic": true, "italic": true,
"bold": true, "bold": true,
"textColor": "0xffD70040", "font_color": "0xffD70040",
"highlightColor": "0x6000BCF0" "bg_color": "0x6000BCF0"
} }
}, },
{ {

View file

@ -3,7 +3,8 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/simple_editor.dart'; import 'package:example/pages/customize_theme_for_editor.dart';
import 'package:example/pages/editor.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -40,6 +41,7 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
final _scaffoldKey = GlobalKey<ScaffoldState>(); final _scaffoldKey = GlobalKey<ScaffoldState>();
late WidgetBuilder _widgetBuilder; late WidgetBuilder _widgetBuilder;
late EditorState _editorState; late EditorState _editorState;
late Future<String> _jsonString; late Future<String> _jsonString;
@ -49,7 +51,7 @@ class _HomePageState extends State<HomePage> {
super.initState(); super.initState();
_jsonString = rootBundle.loadString('assets/example.json'); _jsonString = rootBundle.loadString('assets/example.json');
_widgetBuilder = (context) => SimpleEditor( _widgetBuilder = (context) => Editor(
jsonString: _jsonString, jsonString: _jsonString,
onEditorStateChange: (editorState) { onEditorStateChange: (editorState) {
_editorState = editorState; _editorState = editorState;
@ -61,7 +63,7 @@ class _HomePageState extends State<HomePage> {
void reassemble() { void reassemble() {
super.reassemble(); super.reassemble();
_widgetBuilder = (context) => SimpleEditor( _widgetBuilder = (context) => Editor(
jsonString: _jsonString, jsonString: _jsonString,
onEditorStateChange: (editorState) { onEditorStateChange: (editorState) {
_editorState = editorState; _editorState = editorState;
@ -130,6 +132,17 @@ class _HomePageState extends State<HomePage> {
_loadEditor(context, jsonString); _loadEditor(context, jsonString);
}), }),
// Theme Demo
_buildSeparator(context, 'Theme Demo'),
_buildListTile(context, 'Custom Theme', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CustomizeThemeForEditor(),
),
);
}),
// Encoder Demo // Encoder Demo
_buildSeparator(context, 'Export To X Demo'), _buildSeparator(context, 'Export To X Demo'),
_buildListTile(context, 'Export To JSON', () { _buildListTile(context, 'Export To JSON', () {
@ -150,23 +163,6 @@ class _HomePageState extends State<HomePage> {
_buildListTile(context, 'Import From Quill Delta', () { _buildListTile(context, 'Import From Quill Delta', () {
_importFile(ExportFileType.delta); _importFile(ExportFileType.delta);
}), }),
// Theme Demo
_buildSeparator(context, 'Theme Demo'),
_buildListTile(context, 'Built In Dark Mode', () {
_jsonString = Future<String>.value(
jsonEncode(_editorState.document.toJson()).toString(),
);
setState(() {});
}),
_buildListTile(context, 'Custom Theme', () {
_jsonString = Future<String>.value(
jsonEncode(_editorState.document.toJson()).toString(),
);
setState(() {
// todo: implement it
});
}),
], ],
), ),
); );
@ -220,7 +216,7 @@ class _HomePageState extends State<HomePage> {
_jsonString = jsonString; _jsonString = jsonString;
setState( setState(
() { () {
_widgetBuilder = (context) => SimpleEditor( _widgetBuilder = (context) => Editor(
jsonString: _jsonString, jsonString: _jsonString,
onEditorStateChange: (editorState) { onEditorStateChange: (editorState) {
_editorState = editorState; _editorState = editorState;

View file

@ -0,0 +1,179 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomizeThemeForEditor extends StatefulWidget {
const CustomizeThemeForEditor({super.key});
@override
State<CustomizeThemeForEditor> createState() =>
_CustomizeThemeForEditorState();
}
class _CustomizeThemeForEditorState extends State<CustomizeThemeForEditor> {
late final Future<EditorState> editorState;
@override
void initState() {
super.initState();
editorState = rootBundle.loadString('assets/example.json').then((value) {
return EditorState(
document: Document.fromJson(
Map<String, Object>.from(
json.decode(value),
),
),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text('Custom Theme For Editor'),
titleTextStyle: const TextStyle(color: Colors.white),
iconTheme: const IconThemeData(
color: Colors.white,
),
),
body: FutureBuilder(
future: editorState,
builder: (context, snapshot) {
return !snapshot.hasData
? const Center(child: CircularProgressIndicator())
: buildEditor(snapshot.data!);
},
),
);
}
Widget buildEditor(EditorState editorState) {
return Container(
color: Colors.black,
child: AppFlowyEditor(
editorState: editorState,
editorStyle: customizeEditorStyle(),
blockComponentBuilders: customBuilder(),
),
);
}
/// custom the block style
Map<String, BlockComponentBuilder> customBuilder() {
final configuration = BlockComponentConfiguration(
padding: (node) {
if (HeadingBlockKeys.type == node.type) {
return const EdgeInsets.symmetric(vertical: 30);
}
return const EdgeInsets.symmetric(vertical: 10);
},
textStyle: (node) {
if (HeadingBlockKeys.type == node.type) {
return const TextStyle(color: Colors.yellow);
}
return const TextStyle();
},
);
// customize heading block style
return {
...standardBlockComponentBuilderMap,
// heading block
HeadingBlockKeys.type: HeadingBlockComponentBuilder(
configuration: configuration,
),
// todo-list block
TodoListBlockKeys.type: TodoListBlockComponentBuilder(
configuration: configuration,
iconBuilder: (context, node) {
final checked = node.attributes[TodoListBlockKeys.checked] as bool;
return Icon(
checked ? Icons.check_box : Icons.check_box_outline_blank,
size: 20,
color: Colors.white,
);
},
),
// bulleted list block
BulletedListBlockKeys.type: BulletedListBlockComponentBuilder(
configuration: configuration,
iconBuilder: (context, node) {
return const Icon(
Icons.circle,
size: 20,
color: Colors.green,
);
},
),
// quote block
QuoteBlockKeys.type: QuoteBlockComponentBuilder(
configuration: configuration,
iconBuilder: (context, node) {
return const EditorSvg(
width: 20,
height: 20,
padding: EdgeInsets.only(right: 5.0),
name: 'quote',
color: Colors.pink,
);
},
),
};
}
/// custom the text style
EditorStyle customizeEditorStyle() {
return EditorStyle(
padding: PlatformExtension.isDesktopOrWeb
? const EdgeInsets.only(left: 100, right: 100, top: 20)
: const EdgeInsets.symmetric(horizontal: 20),
cursorColor: Colors.green,
selectionColor: Colors.green,
textStyleConfiguration: TextStyleConfiguration(
text: const TextStyle(
fontSize: 18.0,
color: Colors.white54,
),
bold: const TextStyle(
fontWeight: FontWeight.w900,
),
href: TextStyle(
color: Colors.amber,
decoration: TextDecoration.combine(
[
TextDecoration.overline,
TextDecoration.underline,
],
),
),
code: const TextStyle(
fontSize: 14.0,
fontStyle: FontStyle.italic,
color: Colors.blue,
backgroundColor: Colors.black12,
),
),
textSpanDecorator: (context, node, index, text, textSpan) {
final attributes = text.attributes;
final href = attributes?[AppFlowyRichTextKeys.href];
if (href != null) {
return TextSpan(
text: text.text,
style: textSpan.style,
recognizer: TapGestureRecognizer()
..onTap = () {
debugPrint('onTap: $href');
},
);
}
return textSpan;
},
);
}
}

View file

@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class Editor extends StatelessWidget {
const Editor({
super.key,
required this.jsonString,
required this.onEditorStateChange,
this.editorStyle,
});
final Future<String> jsonString;
final EditorStyle? editorStyle;
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!),
),
),
);
editorState.logConfiguration
..handler = debugPrint
..level = LogLevel.off;
onEditorStateChange(editorState);
final scrollController = ScrollController();
if (PlatformExtension.isDesktopOrWeb) {
return FloatingToolbar(
items: [
paragraphItem,
...headingItems,
...markdownFormatItems,
quoteItem,
bulletedListItem,
numberedListItem,
linkItem,
buildTextColorItem(),
buildHighlightColorItem()
],
editorState: editorState,
scrollController: scrollController,
child: _buildDesktopEditor(
context,
editorState,
scrollController,
),
);
} else if (PlatformExtension.isMobile) {
return Column(
children: [
Expanded(
child: _buildMobileEditor(
context,
editorState,
scrollController,
),
),
MobileToolbar(
editorState: editorState,
toolbarItems: [
textDecorationMobileToolbarItem,
buildTextAndBackgroundColorMobileToolbarItem(),
headingMobileToolbarItem,
todoListMobileToolbarItem,
listMobileToolbarItem,
linkMobileToolbarItem,
quoteMobileToolbarItem,
dividerMobileToolbarItem,
codeMobileToolbarItem,
],
),
],
);
}
}
return const Center(
child: CircularProgressIndicator(),
);
},
);
}
Widget _buildMobileEditor(
BuildContext context,
EditorState editorState,
ScrollController? scrollController,
) {
return AppFlowyEditor(
editorStyle: const EditorStyle.mobile(),
editorState: editorState,
scrollController: scrollController,
);
}
Widget _buildDesktopEditor(
BuildContext context,
EditorState editorState,
ScrollController? scrollController,
) {
final customBlockComponentBuilders = {
...standardBlockComponentBuilderMap,
ImageBlockKeys.type: ImageBlockComponentBuilder(
showMenu: true,
menuBuilder: (node, _) {
return const Positioned(
right: 10,
child: Text('Sample Menu'),
);
},
)
};
return AppFlowyEditor(
editorState: editorState,
scrollController: scrollController,
blockComponentBuilders: customBlockComponentBuilders,
commandShortcutEvents: standardCommandShortcutEvents,
characterShortcutEvents: standardCharacterShortcutEvents,
);
}
}

View file

@ -18,9 +18,9 @@ export 'src/l10n/l10n.dart';
// plugins part, including decoder and encoder. // plugins part, including decoder and encoder.
export 'src/plugins/plugins.dart'; export 'src/plugins/plugins.dart';
// legacy // legacy
export 'src/render/rich_text/default_selectable.dart'; export 'src/editor/block_component/rich_text/default_selectable_mixin.dart';
export 'src/render/rich_text/flowy_rich_text.dart'; export 'src/editor/block_component/rich_text/appflowy_rich_text.dart';
export 'src/render/rich_text/flowy_rich_text_keys.dart'; export 'src/editor/block_component/rich_text/appflowy_rich_text_keys.dart';
export 'src/render/selection/selectable.dart'; export 'src/render/selection/selectable.dart';
export 'src/render/toolbar/toolbar_item.dart'; export 'src/render/toolbar/toolbar_item.dart';
export 'src/service/shortcut_event/key_mapping.dart'; export 'src/service/shortcut_event/key_mapping.dart';

View file

@ -681,7 +681,7 @@ extension on Delta {
final attributes = slice(index - 1, index).first.attributes; final attributes = slice(index - 1, index).first.attributes;
if (attributes == null || if (attributes == null ||
!attributes.keys.every( !attributes.keys.every(
(element) => FlowyRichTextKeys.supportSliced.contains(element), (element) => AppFlowyRichTextKeys.supportSliced.contains(element),
)) { )) {
return null; return null;
} }

View file

@ -22,12 +22,25 @@ class TextStyleConfiguration {
), ),
}); });
/// default text style
final TextStyle text; final TextStyle text;
/// bold text style
final TextStyle bold; final TextStyle bold;
/// italic text style
final TextStyle italic; final TextStyle italic;
/// underline text style
final TextStyle underline; final TextStyle underline;
/// strikethrough text style
final TextStyle strikethrough; final TextStyle strikethrough;
/// href text style
final TextStyle href; final TextStyle href;
/// code text style
final TextStyle code; final TextStyle code;
TextStyleConfiguration copyWith({ TextStyleConfiguration copyWith({

View file

@ -40,6 +40,11 @@ export 'base_component/outdent_command.dart';
export 'base_component/widget/full_screen_overlay_entry.dart'; export 'base_component/widget/full_screen_overlay_entry.dart';
export 'base_component/widget/ignore_parent_pointer.dart'; export 'base_component/widget/ignore_parent_pointer.dart';
// rich text
export 'rich_text/default_selectable_mixin.dart';
export 'rich_text/appflowy_rich_text.dart';
export 'rich_text/appflowy_rich_text_keys.dart';
export 'base_component/block_component_configuration.dart'; export 'base_component/block_component_configuration.dart';
export 'base_component/text_style_configuration.dart'; export 'base_component/text_style_configuration.dart';
export 'base_component/background_color_mixin.dart'; export 'base_component/background_color_mixin.dart';

View file

@ -75,7 +75,7 @@ class _BulletedListBlockComponentWidgetState
extends State<BulletedListBlockComponentWidget> extends State<BulletedListBlockComponentWidget>
with with
SelectableMixin, SelectableMixin,
DefaultSelectable, DefaultSelectableMixin,
BlockComponentConfigurable, BlockComponentConfigurable,
BackgroundColorMixin, BackgroundColorMixin,
NestedBlockComponentStatefulWidgetMixin { NestedBlockComponentStatefulWidgetMixin {
@ -107,7 +107,7 @@ class _BulletedListBlockComponentWidgetState
textStyle: textStyle, textStyle: textStyle,
), ),
Flexible( Flexible(
child: FlowyRichText( child: AppFlowyRichText(
key: forwardKey, key: forwardKey,
node: widget.node, node: widget.node,
editorState: editorState, editorState: editorState,

View file

@ -90,7 +90,7 @@ class _HeadingBlockComponentWidgetState
extends State<HeadingBlockComponentWidget> extends State<HeadingBlockComponentWidget>
with with
SelectableMixin, SelectableMixin,
DefaultSelectable, DefaultSelectableMixin,
BlockComponentConfigurable, BlockComponentConfigurable,
BackgroundColorMixin { BackgroundColorMixin {
@override @override
@ -113,7 +113,7 @@ class _HeadingBlockComponentWidgetState
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = Container( Widget child = Container(
color: backgroundColor, color: backgroundColor,
child: FlowyRichText( child: AppFlowyRichText(
key: forwardKey, key: forwardKey,
node: widget.node, node: widget.node,
editorState: editorState, editorState: editorState,

View file

@ -82,7 +82,7 @@ class _NumberedListBlockComponentWidgetState
extends State<NumberedListBlockComponentWidget> extends State<NumberedListBlockComponentWidget>
with with
SelectableMixin, SelectableMixin,
DefaultSelectable, DefaultSelectableMixin,
BlockComponentConfigurable, BlockComponentConfigurable,
BackgroundColorMixin, BackgroundColorMixin,
NestedBlockComponentStatefulWidgetMixin { NestedBlockComponentStatefulWidgetMixin {
@ -114,7 +114,7 @@ class _NumberedListBlockComponentWidgetState
textStyle: textStyle, textStyle: textStyle,
), ),
Flexible( Flexible(
child: FlowyRichText( child: AppFlowyRichText(
key: forwardKey, key: forwardKey,
node: widget.node, node: widget.node,
editorState: editorState, editorState: editorState,

View file

@ -75,7 +75,7 @@ class QuoteBlockComponentWidget extends BlockComponentStatefulWidget {
class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget> class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
with with
SelectableMixin, SelectableMixin,
DefaultSelectable, DefaultSelectableMixin,
BlockComponentConfigurable, BlockComponentConfigurable,
BackgroundColorMixin { BackgroundColorMixin {
@override @override
@ -106,7 +106,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
? widget.iconBuilder!(context, node) ? widget.iconBuilder!(context, node)
: const _QuoteIcon(), : const _QuoteIcon(),
Flexible( Flexible(
child: FlowyRichText( child: AppFlowyRichText(
key: forwardKey, key: forwardKey,
node: widget.node, node: widget.node,
editorState: editorState, editorState: editorState,

View file

@ -0,0 +1,394 @@
import 'dart:ui';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:appflowy_editor/src/editor/util/color_util.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
typedef TextSpanDecoratorForAttribute = InlineSpan Function(
BuildContext context,
Node node,
int index,
TextInsert text,
TextSpan textSpan,
);
typedef AppFlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
class AppFlowyRichText extends StatefulWidget {
const AppFlowyRichText({
super.key,
this.cursorHeight,
this.cursorWidth = 1.5,
this.lineHeight,
this.textSpanDecorator,
this.placeholderText = ' ',
this.placeholderTextSpanDecorator,
this.textSpanDecoratorForCustomAttributes,
required this.node,
required this.editorState,
});
/// The node of the rich text.
final Node node;
/// The editor state.
final EditorState editorState;
/// The height of the cursor.
///
/// If this is null, the height of the cursor will be calculated automatically.
final double? cursorHeight;
/// The width of the cursor.
final double cursorWidth;
/// The height of each line.
final double? lineHeight;
/// customize the text span for rich text
final AppFlowyTextSpanDecorator? textSpanDecorator;
/// The placeholder text when the rich text is empty.
final String placeholderText;
/// customize the text span for placeholder text
final AppFlowyTextSpanDecorator? placeholderTextSpanDecorator;
/// customize the text span for custom attributes
///
/// You can use this to customize the text span for custom attributes
/// or override the existing one.
final TextSpanDecoratorForAttribute? textSpanDecoratorForCustomAttributes;
@override
State<AppFlowyRichText> createState() => _AppFlowyRichTextState();
}
class _AppFlowyRichTextState extends State<AppFlowyRichText>
with SelectableMixin {
final textKey = GlobalKey();
final placeholderTextKey = GlobalKey();
RenderParagraph get _renderParagraph =>
textKey.currentContext?.findRenderObject() as RenderParagraph;
RenderParagraph? get _placeholderRenderParagraph =>
placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph?;
TextSpanDecoratorForAttribute? get textSpanDecoratorForAttribute =>
widget.textSpanDecoratorForCustomAttributes ??
widget.editorState.editorStyle.textSpanDecorator;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.text,
child: widget.node.delta?.toPlainText().isEmpty ?? true
? Stack(
children: [
_buildPlaceholderText(context),
_buildRichText(context),
],
)
: _buildRichText(context),
);
}
@override
Position start() => Position(path: widget.node.path, offset: 0);
@override
Position end() => Position(
path: widget.node.path,
offset: widget.node.delta?.toPlainText().length ?? 0,
);
@override
Rect? getCursorRectInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset);
var cursorHeight = _renderParagraph.getFullHeightForCaret(textPosition);
var cursorOffset =
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
if (cursorHeight == null) {
cursorHeight =
_placeholderRenderParagraph?.getFullHeightForCaret(textPosition);
cursorOffset = _placeholderRenderParagraph?.getOffsetForCaret(
textPosition,
Rect.zero,
) ??
Offset.zero;
}
if (widget.cursorHeight != null && cursorHeight != null) {
cursorOffset = Offset(
cursorOffset.dx,
cursorOffset.dy + (cursorHeight - widget.cursorHeight!) / 2,
);
cursorHeight = widget.cursorHeight;
}
final rect = Rect.fromLTWH(
cursorOffset.dx - (widget.cursorWidth / 2.0),
cursorOffset.dy,
widget.cursorWidth,
cursorHeight ?? 16.0,
);
return rect;
}
@override
Position getPositionInOffset(Offset start) {
final offset = _renderParagraph.globalToLocal(start);
final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
return Position(path: widget.node.path, offset: baseOffset);
}
@override
Selection? getWordBoundaryInOffset(Offset offset) {
final localOffset = _renderParagraph.globalToLocal(offset);
final textPosition = _renderParagraph.getPositionForOffset(localOffset);
final textRange = _renderParagraph.getWordBoundary(textPosition);
final start = Position(path: widget.node.path, offset: textRange.start);
final end = Position(path: widget.node.path, offset: textRange.end);
return Selection(start: start, end: end);
}
@override
Selection? getWordBoundaryInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset);
final textRange = _renderParagraph.getWordBoundary(textPosition);
final start = Position(path: widget.node.path, offset: textRange.start);
final end = Position(path: widget.node.path, offset: textRange.end);
return Selection(start: start, end: end);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
final textSelection = textSelectionFromEditorSelection(selection);
if (textSelection == null) {
return [];
}
final rects = _renderParagraph
.getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max)
.map((box) => box.toRect())
.toList(growable: false);
if (rects.isEmpty) {
// If the rich text widget does not contain any text,
// there will be no selection boxes,
// so we need to return to the default selection.
return [Rect.fromLTWH(0, 0, 0, _renderParagraph.size.height)];
}
return rects;
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
final localStart = _renderParagraph.globalToLocal(start);
final localEnd = _renderParagraph.globalToLocal(end);
final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
return Selection.single(
path: widget.node.path,
startOffset: baseOffset,
endOffset: extentOffset,
);
}
@override
Offset localToGlobal(Offset offset) {
return _renderParagraph.localToGlobal(offset);
}
Widget _buildPlaceholderText(BuildContext context) {
final textSpan = getPlaceholderTextSpan();
return RichText(
key: placeholderTextKey,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
text: widget.placeholderTextSpanDecorator != null
? widget.placeholderTextSpanDecorator!(textSpan)
: textSpan,
);
}
Widget _buildRichText(BuildContext context) {
final textSpan = getTextSpan();
return RichText(
key: textKey,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
text: widget.textSpanDecorator != null
? widget.textSpanDecorator!(textSpan)
: textSpan,
);
}
TextSpan getPlaceholderTextSpan() {
final style = widget.editorState.editorStyle.textStyleConfiguration;
return TextSpan(
children: [
TextSpan(
text: widget.placeholderText,
style: style.text.copyWith(height: widget.lineHeight),
),
],
);
}
TextSpan getTextSpan() {
int offset = 0;
List<InlineSpan> textSpans = [];
final style = widget.editorState.editorStyle.textStyleConfiguration;
final textInserts = widget.node.delta!.whereType<TextInsert>();
for (final textInsert in textInserts) {
TextStyle textStyle = style.text.copyWith(height: widget.lineHeight);
final attributes = textInsert.attributes;
if (attributes != null) {
if (attributes.bold == true) {
textStyle = textStyle.combine(style.bold);
}
if (attributes.italic == true) {
textStyle = textStyle.combine(style.italic);
}
if (attributes.underline == true) {
textStyle = textStyle.combine(style.underline);
}
if (attributes.strikethrough == true) {
textStyle = textStyle.combine(style.strikethrough);
}
if (attributes.href != null) {
textStyle = textStyle.combine(style.href);
}
if (attributes.code == true) {
textStyle = textStyle.combine(style.code);
}
if (attributes.backgroundColor != null) {
textStyle = textStyle.combine(
TextStyle(backgroundColor: attributes.backgroundColor),
);
}
if (attributes.color != null) {
textStyle = textStyle.combine(
TextStyle(color: attributes.color),
);
}
}
final textSpan = TextSpan(
text: textInsert.text,
style: textStyle,
);
textSpans.add(
textSpanDecoratorForAttribute != null
? textSpanDecoratorForAttribute!(
context,
widget.node,
offset,
textInsert,
textSpan,
)
: textSpan,
);
offset += textInsert.length;
}
return TextSpan(
children: textSpans,
);
}
TextSelection? textSelectionFromEditorSelection(Selection? selection) {
if (selection == null) {
return null;
}
final normalized = selection.normalized;
final path = widget.node.path;
if (path < normalized.start.path || path > normalized.end.path) {
return null;
}
final length = widget.node.delta?.length;
if (length == null) {
return null;
}
TextSelection? textSelection;
if (normalized.isSingle) {
if (path.equals(normalized.start.path)) {
if (normalized.isCollapsed) {
textSelection = TextSelection.collapsed(
offset: normalized.startIndex,
);
} else {
textSelection = TextSelection(
baseOffset: normalized.startIndex,
extentOffset: normalized.endIndex,
);
}
}
} else {
if (path.equals(normalized.start.path)) {
textSelection = TextSelection(
baseOffset: normalized.startIndex,
extentOffset: length,
);
} else if (path.equals(normalized.end.path)) {
textSelection = TextSelection(
baseOffset: 0,
extentOffset: normalized.endIndex,
);
} else {
textSelection = TextSelection(
baseOffset: 0,
extentOffset: length,
);
}
}
return textSelection;
}
}
extension FlowyRichTextAttributes on Attributes {
bool get bold => this[AppFlowyRichTextKeys.bold] == true;
bool get italic => this[AppFlowyRichTextKeys.italic] == true;
bool get underline => this[AppFlowyRichTextKeys.underline] == true;
bool get code => this[AppFlowyRichTextKeys.code] == true;
bool get strikethrough {
return (containsKey(AppFlowyRichTextKeys.strikethrough) &&
this[AppFlowyRichTextKeys.strikethrough] == true);
}
Color? get color {
final textColor = this[AppFlowyRichTextKeys.textColor] as String?;
return textColor?.toColor();
}
Color? get backgroundColor {
final highlightColor = this[AppFlowyRichTextKeys.highlightColor] as String?;
return highlightColor?.toColor();
}
String? get href {
if (this[AppFlowyRichTextKeys.href] is String) {
return this[AppFlowyRichTextKeys.href];
}
return null;
}
}

View file

@ -0,0 +1,19 @@
class AppFlowyRichTextKeys {
static String bold = 'bold';
static String italic = 'italic';
static String underline = 'underline';
static String strikethrough = 'strikethrough';
static String textColor = 'font_color';
static String highlightColor = 'bg_color';
static String code = 'code';
static String href = 'href';
static List<String> supportSliced = [
bold,
italic,
underline,
strikethrough,
textColor,
highlightColor,
];
}

View file

@ -0,0 +1,48 @@
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';
mixin DefaultSelectableMixin {
GlobalKey get forwardKey;
GlobalKey get containerKey;
SelectableMixin<StatefulWidget> get forward =>
forwardKey.currentState as SelectableMixin;
Offset get baseOffset {
final parentBox = containerKey.currentContext?.findRenderObject();
final childBox = forwardKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && childBox is RenderBox) {
return childBox.localToGlobal(Offset.zero, ancestor: parentBox);
}
return Offset.zero;
}
Position getPositionInOffset(Offset start) =>
forward.getPositionInOffset(start);
Rect? getCursorRectInPosition(Position position) =>
forward.getCursorRectInPosition(position)?.shift(baseOffset);
List<Rect> getRectsInSelection(Selection selection) => forward
.getRectsInSelection(selection)
.map((rect) => rect.shift(baseOffset))
.toList(growable: false);
Selection getSelectionInRange(Offset start, Offset end) =>
forward.getSelectionInRange(start, end);
Offset localToGlobal(Offset offset) =>
forward.localToGlobal(offset) - baseOffset;
Selection? getWordBoundaryInOffset(Offset offset) =>
forward.getWordBoundaryInOffset(offset);
Selection? getWordBoundaryInPosition(Position position) =>
forward.getWordBoundaryInPosition(position);
Position start() => forward.start();
Position end() => forward.end();
}

View file

@ -75,7 +75,7 @@ class TextBlockComponentWidget extends BlockComponentStatefulWidget {
class _TextBlockComponentWidgetState extends State<TextBlockComponentWidget> class _TextBlockComponentWidgetState extends State<TextBlockComponentWidget>
with with
SelectableMixin, SelectableMixin,
DefaultSelectable, DefaultSelectableMixin,
BlockComponentConfigurable, BlockComponentConfigurable,
BackgroundColorMixin, BackgroundColorMixin,
NestedBlockComponentStatefulWidgetMixin { NestedBlockComponentStatefulWidgetMixin {
@ -95,7 +95,7 @@ class _TextBlockComponentWidgetState extends State<TextBlockComponentWidget>
Widget buildComponent(BuildContext context) { Widget buildComponent(BuildContext context) {
Widget child = Container( Widget child = Container(
color: backgroundColor, color: backgroundColor,
child: FlowyRichText( child: AppFlowyRichText(
key: forwardKey, key: forwardKey,
node: widget.node, node: widget.node,
editorState: editorState, editorState: editorState,

View file

@ -92,7 +92,7 @@ class _TodoListBlockComponentWidgetState
extends State<TodoListBlockComponentWidget> extends State<TodoListBlockComponentWidget>
with with
SelectableMixin, SelectableMixin,
DefaultSelectable, DefaultSelectableMixin,
BlockComponentConfigurable, BlockComponentConfigurable,
BackgroundColorMixin, BackgroundColorMixin,
NestedBlockComponentStatefulWidgetMixin { NestedBlockComponentStatefulWidgetMixin {
@ -126,7 +126,7 @@ class _TodoListBlockComponentWidgetState
onTap: checkOrUncheck, onTap: checkOrUncheck,
), ),
Flexible( Flexible(
child: FlowyRichText( child: AppFlowyRichText(
key: forwardKey, key: forwardKey,
node: widget.node, node: widget.node,
editorState: editorState, editorState: editorState,

Some files were not shown because too many files have changed in this diff Show more