feat: customize href (#293)
This commit is contained in:
parent
c5b5e641fe
commit
46158d9e26
61 changed files with 766 additions and 1635 deletions
12
README.md
12
README.md
|
|
@ -56,7 +56,7 @@ Start by creating a new empty AppFlowyEditor object.
|
|||
|
||||
```dart
|
||||
final editorState = EditorState.blank(withInitialText: true); // with an empty paragraph
|
||||
final editor = AppFlowyEditor.standard(
|
||||
final editor = AppFlowyEditor(
|
||||
editorState: editorState,
|
||||
);
|
||||
```
|
||||
|
|
@ -66,7 +66,7 @@ You can also create an editor from a JSON object in order to configure your init
|
|||
```dart
|
||||
final json = jsonDecode('YOUR INPUT JSON STRING');
|
||||
final editorState = EditorState(document: Document.fromJson(json));
|
||||
final editor = AppFlowyEditor.standard(
|
||||
final editor = AppFlowyEditor(
|
||||
editorState: editorState,
|
||||
);
|
||||
```
|
||||
|
|
@ -90,6 +90,12 @@ flutter run
|
|||
|
||||
## 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
|
||||
|
||||
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
|
||||
* [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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
## Customizing a Component
|
||||
|
||||
> ⚠️ 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(),
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||

|
||||
|
||||
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)
|
||||
## Customizing 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(
|
||||
alignment: Alignment.topCenter,
|
||||
child: AppFlowyEditor(
|
||||
editorState: EditorState.empty(),
|
||||
shortcutEvents: const [],
|
||||
customBuilders: const {},
|
||||
editorState: EditorState.blank(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -283,44 +113,118 @@ At this point, the editor looks like ...
|
|||

|
||||
|
||||
|
||||
Next, we will customize the `EditorStyle`.
|
||||
Next, we will customize the `EditorStyle` and the block style with `BlockComponentConfiguration`.
|
||||
|
||||
```dart
|
||||
ThemeData customizeEditorTheme(BuildContext context) {
|
||||
final dark = EditorStyle.dark;
|
||||
final editorStyle = dark.copyWith(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 150),
|
||||
cursorColor: Colors.red.shade600,
|
||||
selectionColor: Colors.yellow.shade600.withOpacity(0.5),
|
||||
textStyle: GoogleFonts.poppins().copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
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,
|
||||
),
|
||||
),
|
||||
placeholderTextStyle: GoogleFonts.poppins().copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
code: dark.code?.copyWith(
|
||||
backgroundColor: Colors.lightBlue.shade200,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
highlightColorHex: '0x60FF0000', // red
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
||||
final quote = QuotedTextPluginStyle.dark.copyWith(
|
||||
textStyle: (_, __) => GoogleFonts.poppins().copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.blue.shade400,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w700,
|
||||
// customize heading block style
|
||||
return {
|
||||
...standardBlockComponentBuilderMap,
|
||||
// heading block
|
||||
HeadingBlockKeys.type: HeadingBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
),
|
||||
);
|
||||
|
||||
return Theme.of(context).copyWith(extensions: [
|
||||
editorStyle,
|
||||
...darkPlguinStyleExtension,
|
||||
quote,
|
||||
]);
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -333,72 +237,13 @@ Widget build(BuildContext context) {
|
|||
body: Container(
|
||||
alignment: Alignment.topCenter,
|
||||
child: AppFlowyEditor(
|
||||
editorState: EditorState.empty(),
|
||||
themeData: customizeEditorTheme(context),
|
||||
shortcutEvents: const [],
|
||||
customBuilders: const {},
|
||||
editorState: EditorState.blank(),
|
||||
editorStyle: customizeEditorStyle(),
|
||||
blockComponentBuilders: customBuilder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 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 |
|
|
@ -128,8 +128,8 @@
|
|||
"attributes": {
|
||||
"italic": true,
|
||||
"bold": true,
|
||||
"textColor": "0xffD70040",
|
||||
"highlightColor": "0x6000BCF0"
|
||||
"font_color": "0xffD70040",
|
||||
"bg_color": "0x6000BCF0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
|
||||
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:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -40,6 +41,7 @@ class HomePage extends StatefulWidget {
|
|||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
late WidgetBuilder _widgetBuilder;
|
||||
late EditorState _editorState;
|
||||
late Future<String> _jsonString;
|
||||
|
|
@ -49,7 +51,7 @@ class _HomePageState extends State<HomePage> {
|
|||
super.initState();
|
||||
|
||||
_jsonString = rootBundle.loadString('assets/example.json');
|
||||
_widgetBuilder = (context) => SimpleEditor(
|
||||
_widgetBuilder = (context) => Editor(
|
||||
jsonString: _jsonString,
|
||||
onEditorStateChange: (editorState) {
|
||||
_editorState = editorState;
|
||||
|
|
@ -61,7 +63,7 @@ class _HomePageState extends State<HomePage> {
|
|||
void reassemble() {
|
||||
super.reassemble();
|
||||
|
||||
_widgetBuilder = (context) => SimpleEditor(
|
||||
_widgetBuilder = (context) => Editor(
|
||||
jsonString: _jsonString,
|
||||
onEditorStateChange: (editorState) {
|
||||
_editorState = editorState;
|
||||
|
|
@ -130,6 +132,17 @@ class _HomePageState extends State<HomePage> {
|
|||
_loadEditor(context, jsonString);
|
||||
}),
|
||||
|
||||
// Theme Demo
|
||||
_buildSeparator(context, 'Theme Demo'),
|
||||
_buildListTile(context, 'Custom Theme', () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CustomizeThemeForEditor(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
// Encoder Demo
|
||||
_buildSeparator(context, 'Export To X Demo'),
|
||||
_buildListTile(context, 'Export To JSON', () {
|
||||
|
|
@ -150,23 +163,6 @@ class _HomePageState extends State<HomePage> {
|
|||
_buildListTile(context, 'Import From Quill 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;
|
||||
setState(
|
||||
() {
|
||||
_widgetBuilder = (context) => SimpleEditor(
|
||||
_widgetBuilder = (context) => Editor(
|
||||
jsonString: _jsonString,
|
||||
onEditorStateChange: (editorState) {
|
||||
_editorState = editorState;
|
||||
|
|
|
|||
179
example/lib/pages/customize_theme_for_editor.dart
Normal file
179
example/lib/pages/customize_theme_for_editor.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
130
example/lib/pages/editor.dart
Normal file
130
example/lib/pages/editor.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,9 @@ export 'src/l10n/l10n.dart';
|
|||
// plugins part, including decoder and encoder.
|
||||
export 'src/plugins/plugins.dart';
|
||||
// legacy
|
||||
export 'src/render/rich_text/default_selectable.dart';
|
||||
export 'src/render/rich_text/flowy_rich_text.dart';
|
||||
export 'src/render/rich_text/flowy_rich_text_keys.dart';
|
||||
export 'src/editor/block_component/rich_text/default_selectable_mixin.dart';
|
||||
export 'src/editor/block_component/rich_text/appflowy_rich_text.dart';
|
||||
export 'src/editor/block_component/rich_text/appflowy_rich_text_keys.dart';
|
||||
export 'src/render/selection/selectable.dart';
|
||||
export 'src/render/toolbar/toolbar_item.dart';
|
||||
export 'src/service/shortcut_event/key_mapping.dart';
|
||||
|
|
|
|||
|
|
@ -681,7 +681,7 @@ extension on Delta {
|
|||
final attributes = slice(index - 1, index).first.attributes;
|
||||
if (attributes == null ||
|
||||
!attributes.keys.every(
|
||||
(element) => FlowyRichTextKeys.supportSliced.contains(element),
|
||||
(element) => AppFlowyRichTextKeys.supportSliced.contains(element),
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,25 @@ class TextStyleConfiguration {
|
|||
),
|
||||
});
|
||||
|
||||
/// default text style
|
||||
final TextStyle text;
|
||||
|
||||
/// bold text style
|
||||
final TextStyle bold;
|
||||
|
||||
/// italic text style
|
||||
final TextStyle italic;
|
||||
|
||||
/// underline text style
|
||||
final TextStyle underline;
|
||||
|
||||
/// strikethrough text style
|
||||
final TextStyle strikethrough;
|
||||
|
||||
/// href text style
|
||||
final TextStyle href;
|
||||
|
||||
/// code text style
|
||||
final TextStyle code;
|
||||
|
||||
TextStyleConfiguration copyWith({
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ export 'base_component/outdent_command.dart';
|
|||
export 'base_component/widget/full_screen_overlay_entry.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/text_style_configuration.dart';
|
||||
export 'base_component/background_color_mixin.dart';
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ class _BulletedListBlockComponentWidgetState
|
|||
extends State<BulletedListBlockComponentWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
DefaultSelectable,
|
||||
DefaultSelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BackgroundColorMixin,
|
||||
NestedBlockComponentStatefulWidgetMixin {
|
||||
|
|
@ -107,7 +107,7 @@ class _BulletedListBlockComponentWidgetState
|
|||
textStyle: textStyle,
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
child: AppFlowyRichText(
|
||||
key: forwardKey,
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class _HeadingBlockComponentWidgetState
|
|||
extends State<HeadingBlockComponentWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
DefaultSelectable,
|
||||
DefaultSelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BackgroundColorMixin {
|
||||
@override
|
||||
|
|
@ -113,7 +113,7 @@ class _HeadingBlockComponentWidgetState
|
|||
Widget build(BuildContext context) {
|
||||
Widget child = Container(
|
||||
color: backgroundColor,
|
||||
child: FlowyRichText(
|
||||
child: AppFlowyRichText(
|
||||
key: forwardKey,
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class _NumberedListBlockComponentWidgetState
|
|||
extends State<NumberedListBlockComponentWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
DefaultSelectable,
|
||||
DefaultSelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BackgroundColorMixin,
|
||||
NestedBlockComponentStatefulWidgetMixin {
|
||||
|
|
@ -114,7 +114,7 @@ class _NumberedListBlockComponentWidgetState
|
|||
textStyle: textStyle,
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
child: AppFlowyRichText(
|
||||
key: forwardKey,
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ class QuoteBlockComponentWidget extends BlockComponentStatefulWidget {
|
|||
class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
DefaultSelectable,
|
||||
DefaultSelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BackgroundColorMixin {
|
||||
@override
|
||||
|
|
@ -106,7 +106,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
|
|||
? widget.iconBuilder!(context, node)
|
||||
: const _QuoteIcon(),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
child: AppFlowyRichText(
|
||||
key: forwardKey,
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
|
|
|
|||
394
lib/src/editor/block_component/rich_text/appflowy_rich_text.dart
Normal file
394
lib/src/editor/block_component/rich_text/appflowy_rich_text.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ class TextBlockComponentWidget extends BlockComponentStatefulWidget {
|
|||
class _TextBlockComponentWidgetState extends State<TextBlockComponentWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
DefaultSelectable,
|
||||
DefaultSelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BackgroundColorMixin,
|
||||
NestedBlockComponentStatefulWidgetMixin {
|
||||
|
|
@ -95,7 +95,7 @@ class _TextBlockComponentWidgetState extends State<TextBlockComponentWidget>
|
|||
Widget buildComponent(BuildContext context) {
|
||||
Widget child = Container(
|
||||
color: backgroundColor,
|
||||
child: FlowyRichText(
|
||||
child: AppFlowyRichText(
|
||||
key: forwardKey,
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class _TodoListBlockComponentWidgetState
|
|||
extends State<TodoListBlockComponentWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
DefaultSelectable,
|
||||
DefaultSelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BackgroundColorMixin,
|
||||
NestedBlockComponentStatefulWidgetMixin {
|
||||
|
|
@ -126,7 +126,7 @@ class _TodoListBlockComponentWidgetState
|
|||
onTap: checkOrUncheck,
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
child: AppFlowyRichText(
|
||||
key: forwardKey,
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue