feat: support customizing text attribute key and rendering (#244)
This commit is contained in:
parent
cd0f67a48e
commit
d359da89c9
14 changed files with 187 additions and 1012 deletions
|
|
@ -1,234 +0,0 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// copy from https://docs.flutter.dev/cookbook/effects/expandable-fab
|
||||
@immutable
|
||||
class ExpandableFab extends StatefulWidget {
|
||||
const ExpandableFab({
|
||||
super.key,
|
||||
this.initialOpen,
|
||||
required this.distance,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final bool? initialOpen;
|
||||
final double distance;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
State<ExpandableFab> createState() => _ExpandableFabState();
|
||||
}
|
||||
|
||||
class _ExpandableFabState extends State<ExpandableFab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _expandAnimation;
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_open = widget.initialOpen ?? false;
|
||||
_controller = AnimationController(
|
||||
value: _open ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
_expandAnimation = CurvedAnimation(
|
||||
curve: Curves.fastOutSlowIn,
|
||||
reverseCurve: Curves.easeOutQuad,
|
||||
parent: _controller,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() {
|
||||
_open = !_open;
|
||||
if (_open) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildTapToCloseFab(),
|
||||
..._buildExpandingActionButtons(),
|
||||
_buildTapToOpenFab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTapToCloseFab() {
|
||||
return SizedBox(
|
||||
width: 56.0,
|
||||
height: 56.0,
|
||||
child: Center(
|
||||
child: Material(
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 4.0,
|
||||
child: InkWell(
|
||||
onTap: _toggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildExpandingActionButtons() {
|
||||
final children = <Widget>[];
|
||||
final count = widget.children.length;
|
||||
final step = 90.0 / (count - 1);
|
||||
for (var i = 0, angleInDegrees = 0.0;
|
||||
i < count;
|
||||
i++, angleInDegrees += step) {
|
||||
children.add(
|
||||
_ExpandingActionButton(
|
||||
directionInDegrees: angleInDegrees,
|
||||
maxDistance: widget.distance,
|
||||
progress: _expandAnimation,
|
||||
child: widget.children[i],
|
||||
),
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
Widget _buildTapToOpenFab() {
|
||||
return IgnorePointer(
|
||||
ignoring: _open,
|
||||
child: AnimatedContainer(
|
||||
transformAlignment: Alignment.center,
|
||||
transform: Matrix4.diagonal3Values(
|
||||
_open ? 0.7 : 1.0,
|
||||
_open ? 0.7 : 1.0,
|
||||
1.0,
|
||||
),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||
child: AnimatedOpacity(
|
||||
opacity: _open ? 0.0 : 1.0,
|
||||
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: FloatingActionButton(
|
||||
onPressed: _toggle,
|
||||
child: const Icon(Icons.create),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class _ExpandingActionButton extends StatelessWidget {
|
||||
const _ExpandingActionButton({
|
||||
required this.directionInDegrees,
|
||||
required this.maxDistance,
|
||||
required this.progress,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final double directionInDegrees;
|
||||
final double maxDistance;
|
||||
final Animation<double> progress;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: progress,
|
||||
builder: (context, child) {
|
||||
final offset = Offset.fromDirection(
|
||||
directionInDegrees * (math.pi / 180.0),
|
||||
progress.value * maxDistance,
|
||||
);
|
||||
return Positioned(
|
||||
right: 4.0 + offset.dx,
|
||||
bottom: 4.0 + offset.dy,
|
||||
child: Transform.rotate(
|
||||
angle: (1.0 - progress.value) * math.pi / 2,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: FadeTransition(
|
||||
opacity: progress,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ActionButton extends StatelessWidget {
|
||||
const ActionButton({
|
||||
super.key,
|
||||
this.onPressed,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final Widget icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: theme.colorScheme.secondary,
|
||||
elevation: 4.0,
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
color: theme.colorScheme.onSecondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FakeItem extends StatelessWidget {
|
||||
const FakeItem({
|
||||
super.key,
|
||||
required this.isBig,
|
||||
});
|
||||
|
||||
final bool isBig;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0),
|
||||
height: isBig ? 128.0 : 36.0,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,10 @@ import 'dart:io';
|
|||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:example/pages/simple_editor.dart';
|
||||
import 'package:example/plugin/AI/text_robot.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
|
||||
enum ExportFileType {
|
||||
|
|
@ -45,12 +43,6 @@ class _HomePageState extends State<HomePage> {
|
|||
late WidgetBuilder _widgetBuilder;
|
||||
late EditorState _editorState;
|
||||
late Future<String> _jsonString;
|
||||
ThemeData _themeData = ThemeData.light().copyWith(
|
||||
extensions: [
|
||||
...lightEditorStyleExtension,
|
||||
...lightPluginStyleExtension,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -59,7 +51,6 @@ class _HomePageState extends State<HomePage> {
|
|||
_jsonString = rootBundle.loadString('assets/example.json');
|
||||
_widgetBuilder = (context) => SimpleEditor(
|
||||
jsonString: _jsonString,
|
||||
themeData: _themeData,
|
||||
onEditorStateChange: (editorState) {
|
||||
_editorState = editorState;
|
||||
},
|
||||
|
|
@ -74,7 +65,7 @@ class _HomePageState extends State<HomePage> {
|
|||
drawer: _buildDrawer(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: Text('AppFlowy Editor'),
|
||||
title: const Text('AppFlowy Editor'),
|
||||
),
|
||||
body: SafeArea(child: _buildBody(context)),
|
||||
);
|
||||
|
|
@ -110,7 +101,9 @@ class _HomePageState extends State<HomePage> {
|
|||
html.toJson(),
|
||||
).toString(),
|
||||
);
|
||||
_loadEditor(context, jsonString);
|
||||
if (mounted) {
|
||||
_loadEditor(context, jsonString);
|
||||
}
|
||||
}),
|
||||
_buildListTile(context, 'With Empty Document', () {
|
||||
final jsonString = Future<String>.value(
|
||||
|
|
@ -122,30 +115,30 @@ class _HomePageState extends State<HomePage> {
|
|||
}),
|
||||
|
||||
// Text Robot
|
||||
_buildSeparator(context, 'Text Robot'),
|
||||
_buildListTile(context, 'Type Text Automatically', () async {
|
||||
final jsonString = Future<String>.value(
|
||||
jsonEncode(
|
||||
EditorState.blank(withInitialText: true).document.toJson(),
|
||||
).toString(),
|
||||
);
|
||||
await _loadEditor(context, jsonString);
|
||||
// _buildSeparator(context, 'Text Robot'),
|
||||
// _buildListTile(context, 'Type Text Automatically', () async {
|
||||
// final jsonString = Future<String>.value(
|
||||
// jsonEncode(
|
||||
// EditorState.blank(withInitialText: true).document.toJson(),
|
||||
// ).toString(),
|
||||
// );
|
||||
// await _loadEditor(context, jsonString);
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
final textRobot = TextRobot(
|
||||
editorState: _editorState,
|
||||
);
|
||||
textRobot.insertText(
|
||||
'''
|
||||
Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
||||
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?"
|
||||
// Future.delayed(const Duration(seconds: 2), () {
|
||||
// final textRobot = TextRobot(
|
||||
// editorState: _editorState,
|
||||
// );
|
||||
// textRobot.insertText(
|
||||
// '''
|
||||
// Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
||||
// "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?"
|
||||
|
||||
1914 translation by H. Rackham
|
||||
"But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?"
|
||||
''',
|
||||
);
|
||||
});
|
||||
}),
|
||||
// 1914 translation by H. Rackham
|
||||
// "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?"
|
||||
// ''',
|
||||
// );
|
||||
// });
|
||||
// }),
|
||||
|
||||
// Encoder Demo
|
||||
_buildSeparator(context, 'Encoder Demo'),
|
||||
|
|
@ -174,21 +167,14 @@ Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|||
_jsonString = Future<String>.value(
|
||||
jsonEncode(_editorState.document.toJson()).toString(),
|
||||
);
|
||||
setState(() {
|
||||
_themeData = ThemeData.dark().copyWith(
|
||||
extensions: [
|
||||
...darkEditorStyleExtension,
|
||||
...darkPluginStyleExtension,
|
||||
],
|
||||
);
|
||||
});
|
||||
setState(() {});
|
||||
}),
|
||||
_buildListTile(context, 'Custom Theme', () {
|
||||
_jsonString = Future<String>.value(
|
||||
jsonEncode(_editorState.document.toJson()).toString(),
|
||||
);
|
||||
setState(() {
|
||||
_themeData = _customizeEditorTheme(context);
|
||||
// todo: implement it
|
||||
});
|
||||
}),
|
||||
],
|
||||
|
|
@ -236,15 +222,6 @@ Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingActionButton(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
onPressed: () {
|
||||
_scaffoldKey.currentState?.openDrawer();
|
||||
},
|
||||
child: const Icon(Icons.menu),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadEditor(
|
||||
BuildContext context,
|
||||
Future<String> jsonString,
|
||||
|
|
@ -255,7 +232,6 @@ Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|||
() {
|
||||
_widgetBuilder = (context) => SimpleEditor(
|
||||
jsonString: _jsonString,
|
||||
themeData: _themeData,
|
||||
onEditorStateChange: (editorState) {
|
||||
_editorState = editorState;
|
||||
},
|
||||
|
|
@ -340,15 +316,7 @@ Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|||
jsonString = jsonEncode(markdownToDocument(plainText).toJson());
|
||||
break;
|
||||
case ExportFileType.delta:
|
||||
jsonString = jsonEncode(
|
||||
DeltaDocumentConvert()
|
||||
.convertFromJSON(
|
||||
jsonDecode(
|
||||
plainText.replaceAll('\\\\\n', '\\n'),
|
||||
),
|
||||
)
|
||||
.toJson(),
|
||||
);
|
||||
jsonString = 'unsupported';
|
||||
break;
|
||||
case ExportFileType.html:
|
||||
throw UnimplementedError();
|
||||
|
|
@ -358,43 +326,4 @@ Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|||
_loadEditor(context, Future<String>.value(jsonString));
|
||||
}
|
||||
}
|
||||
|
||||
ThemeData _customizeEditorTheme(BuildContext context) {
|
||||
final dark = EditorStyle.dark;
|
||||
final editorStyle = dark.copyWith(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 150),
|
||||
cursorColor: Colors.blue.shade600,
|
||||
selectionColor: Colors.yellow.shade600.withOpacity(0.5),
|
||||
textStyle: GoogleFonts.poppins().copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
placeholderTextStyle: GoogleFonts.poppins().copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
code: dark.code?.copyWith(
|
||||
backgroundColor: Colors.lightBlue.shade200,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
highlightColorHex: '0x60FF0000', // red
|
||||
);
|
||||
|
||||
final quote = QuotedTextPluginStyle.dark.copyWith(
|
||||
textStyle: (_, __) => GoogleFonts.poppins().copyWith(
|
||||
fontSize: 14,
|
||||
color: Colors.blue.shade400,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
|
||||
return Theme.of(context).copyWith(
|
||||
extensions: [
|
||||
editorStyle,
|
||||
...darkPluginStyleExtension,
|
||||
quote,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@ class MyApp extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: [
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
AppFlowyEditorLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: [Locale('en', 'US')],
|
||||
supportedLocales: const [Locale('en', 'US')],
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: MyHomePage(title: 'AppFlowyEditor Example'),
|
||||
home: const MyHomePage(title: 'AppFlowyEditor Example'),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ class SimpleEditor extends StatelessWidget {
|
|||
const SimpleEditor({
|
||||
super.key,
|
||||
required this.jsonString,
|
||||
required this.themeData,
|
||||
required this.onEditorStateChange,
|
||||
this.editorStyle,
|
||||
});
|
||||
|
||||
final Future<String> jsonString;
|
||||
final ThemeData themeData;
|
||||
final EditorStyle? editorStyle;
|
||||
final void Function(EditorState editorState) onEditorStateChange;
|
||||
|
||||
@override
|
||||
|
|
@ -31,7 +31,7 @@ class SimpleEditor extends StatelessWidget {
|
|||
);
|
||||
editorState.logConfiguration
|
||||
..handler = debugPrint
|
||||
..level = LogLevel.all;
|
||||
..level = LogLevel.off;
|
||||
onEditorStateChange(editorState);
|
||||
final scrollController = ScrollController();
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
|
|
@ -49,7 +49,7 @@ class SimpleEditor extends StatelessWidget {
|
|||
],
|
||||
editorState: editorState,
|
||||
scrollController: scrollController,
|
||||
child: _buildEditor(
|
||||
child: _buildDesktopEditor(
|
||||
context,
|
||||
editorState,
|
||||
scrollController,
|
||||
|
|
@ -101,12 +101,44 @@ class SimpleEditor extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildEditor(
|
||||
Widget _buildDesktopEditor(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
ScrollController? scrollController,
|
||||
) {
|
||||
final editorStyle = EditorStyle.desktop(
|
||||
// Example for customizing a new attribute key.
|
||||
textSpanDecorator: (textInsert, textSpan) {
|
||||
final attributes = textInsert.attributes;
|
||||
if (attributes == null) {
|
||||
return textSpan;
|
||||
}
|
||||
final mention = attributes['mention'] as Map?;
|
||||
if (mention != null) {
|
||||
return WidgetSpan(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
debugPrint('at');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.edit_document),
|
||||
Text(mention['id']),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return textSpan;
|
||||
},
|
||||
);
|
||||
return AppFlowyEditor.standard(
|
||||
editorStyle: editorStyle,
|
||||
editorState: editorState,
|
||||
scrollController: scrollController,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:example/plugin/AI/gpt3.dart';
|
||||
import 'package:example/plugin/AI/text_robot.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
SelectionMenuItem autoCompletionMenuItem = SelectionMenuItem(
|
||||
name: 'Auto generate content',
|
||||
icon: (editorState, onSelected, style) => Icon(
|
||||
Icons.rocket,
|
||||
size: 18.0,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
),
|
||||
keywords: ['auto generate content', 'open ai', 'gpt3', 'ai'],
|
||||
handler: ((editorState, menuService, context) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final controller = TextEditingController(text: '');
|
||||
return AlertDialog(
|
||||
content: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Please input something...',
|
||||
),
|
||||
),
|
||||
onKey: (key) {
|
||||
if (key is! RawKeyDownEvent) return;
|
||||
if (key.logicalKey == LogicalKeyboardKey.enter) {
|
||||
Navigator.of(context).pop();
|
||||
// fetch the result and insert it
|
||||
final textRobot = TextRobot(editorState: editorState);
|
||||
const gpt3 = GPT3APIClient(apiKey: apiKey);
|
||||
gpt3.getGPT3Completion(
|
||||
controller.text,
|
||||
'',
|
||||
onResult: (result) async {
|
||||
await textRobot.insertText(
|
||||
result,
|
||||
inputType: TextRobotInputType.character,
|
||||
);
|
||||
},
|
||||
onError: () async {},
|
||||
);
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:example/plugin/AI/gpt3.dart';
|
||||
import 'package:example/plugin/AI/text_robot.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
SelectionMenuItem continueToWriteMenuItem = SelectionMenuItem(
|
||||
name: 'Continue To Write',
|
||||
icon: (editorState, onSelected, style) => Icon(
|
||||
Icons.print,
|
||||
size: 18.0,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
),
|
||||
keywords: ['continue to write'],
|
||||
handler: ((editorState, menuService, context) async {
|
||||
// Two cases
|
||||
// 1. if there is content in the text node where the cursor is located,
|
||||
// then we use the current text content as data.
|
||||
// 2. if there is no content in the text node where the cursor is located,
|
||||
// then we use the previous / next text node's content as data.
|
||||
|
||||
final selection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (textNodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final textRobot = TextRobot(editorState: editorState);
|
||||
const gpt3 = GPT3APIClient(apiKey: apiKey);
|
||||
final textNode = textNodes.first;
|
||||
|
||||
var prompt = '';
|
||||
var suffix = '';
|
||||
|
||||
void continueToWriteInSingleLine() {
|
||||
prompt = textNode.delta.slice(0, selection.startIndex).toPlainText();
|
||||
suffix = textNode.delta
|
||||
.slice(
|
||||
selection.endIndex,
|
||||
textNode.toPlainText().length,
|
||||
)
|
||||
.toPlainText();
|
||||
}
|
||||
|
||||
void continueToWriteInMulitLines() {
|
||||
final parent = textNode.parent;
|
||||
if (parent != null) {
|
||||
for (final node in parent.children) {
|
||||
if (node is! TextNode || node.toPlainText().isEmpty) continue;
|
||||
if (node.path < textNode.path) {
|
||||
prompt += '${node.toPlainText()}\n';
|
||||
} else if (node.path > textNode.path) {
|
||||
suffix += '${node.toPlainText()}\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (textNodes.first.toPlainText().isNotEmpty) {
|
||||
continueToWriteInSingleLine();
|
||||
} else {
|
||||
continueToWriteInMulitLines();
|
||||
}
|
||||
|
||||
if (prompt.isEmpty && suffix.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
late final BuildContext diglogContext;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
diglogContext = context;
|
||||
return const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 10),
|
||||
Text('Loading'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
gpt3.getGPT3Completion(
|
||||
prompt,
|
||||
suffix,
|
||||
onResult: (result) async {
|
||||
Navigator.of(diglogContext).pop(true);
|
||||
await textRobot.insertText(
|
||||
result,
|
||||
inputType: TextRobotInputType.word,
|
||||
);
|
||||
},
|
||||
onError: () async {
|
||||
Navigator.of(diglogContext).pop(true);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import 'package:http/http.dart' as http;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
// Please fill in your own API key
|
||||
const apiKey = '';
|
||||
|
||||
enum GPT3API {
|
||||
completion,
|
||||
edit,
|
||||
}
|
||||
|
||||
extension on GPT3API {
|
||||
Uri get uri {
|
||||
switch (this) {
|
||||
case GPT3API.completion:
|
||||
return Uri.parse('https://api.openai.com/v1/completions');
|
||||
case GPT3API.edit:
|
||||
return Uri.parse('https://api.openai.com/v1/edits');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GPT3APIClient {
|
||||
const GPT3APIClient({
|
||||
required this.apiKey,
|
||||
});
|
||||
|
||||
final String apiKey;
|
||||
|
||||
/// Get completions from GPT-3
|
||||
///
|
||||
/// [prompt] is the prompt text
|
||||
/// [suffix] is the suffix text
|
||||
/// [onResult] is the callback function to handle the result
|
||||
/// [maxTokens] is the maximum number of tokens to generate
|
||||
/// [temperature] is the temperature of the model
|
||||
///
|
||||
/// See https://beta.openai.com/docs/api-reference/completions/create
|
||||
Future<void> getGPT3Completion(
|
||||
String prompt,
|
||||
String suffix, {
|
||||
required Future<void> Function(String result) onResult,
|
||||
required Future<void> Function() onError,
|
||||
int maxTokens = 200,
|
||||
double temperature = .3,
|
||||
}) async {
|
||||
final data = {
|
||||
'model': 'text-davinci-003',
|
||||
'prompt': prompt,
|
||||
'suffix': suffix,
|
||||
'max_tokens': maxTokens,
|
||||
'temperature': temperature,
|
||||
'stream': false,
|
||||
};
|
||||
|
||||
final headers = {
|
||||
'Authorization': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
GPT3API.completion.uri,
|
||||
headers: headers,
|
||||
body: json.encode(data),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final result = json.decode(response.body);
|
||||
final choices = result['choices'];
|
||||
if (choices != null && choices is List) {
|
||||
for (final choice in choices) {
|
||||
final text = choice['text'];
|
||||
await onResult(text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await onError();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getGPT3Edit(
|
||||
String apiKey,
|
||||
String input,
|
||||
String instruction, {
|
||||
required Future<void> Function(List<String> result) onResult,
|
||||
required Future<void> Function() onError,
|
||||
int n = 1,
|
||||
double temperature = .3,
|
||||
}) async {
|
||||
final data = {
|
||||
'model': 'text-davinci-edit-001',
|
||||
'input': input,
|
||||
'instruction': instruction,
|
||||
'temperature': temperature,
|
||||
'n': n,
|
||||
};
|
||||
|
||||
final headers = {
|
||||
'Authorization': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('https://api.openai.com/v1/edits'),
|
||||
headers: headers,
|
||||
body: json.encode(data),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final result = json.decode(response.body);
|
||||
final choices = result['choices'];
|
||||
if (choices != null && choices is List) {
|
||||
await onResult(choices.map((e) => e['text'] as String).toList());
|
||||
}
|
||||
} else {
|
||||
await onError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:example/plugin/AI/gpt3.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
ToolbarItem smartEditItem = ToolbarItem(
|
||||
id: 'appflowy.toolbar.smart_edit',
|
||||
type: 5,
|
||||
group: 5,
|
||||
iconBuilder: (isHighlight) {
|
||||
return Icon(
|
||||
Icons.edit,
|
||||
color: isHighlight ? Colors.lightBlue : Colors.white,
|
||||
size: 14,
|
||||
);
|
||||
},
|
||||
validator: (editorState) {
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
return nodes.whereType<TextNode>().length == nodes.length &&
|
||||
1 == nodes.length;
|
||||
},
|
||||
highlightCallback: (_) => false,
|
||||
tooltipsMessage: 'Smart Edit',
|
||||
handler: (editorState, context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
content: SmartEditWidget(
|
||||
editorState: editorState,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
class SmartEditWidget extends StatefulWidget {
|
||||
const SmartEditWidget({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<SmartEditWidget> createState() => _SmartEditWidgetState();
|
||||
}
|
||||
|
||||
class _SmartEditWidgetState extends State<SmartEditWidget> {
|
||||
final inputEventController = TextEditingController(text: '');
|
||||
final resultController = TextEditingController(text: '');
|
||||
|
||||
var result = '';
|
||||
|
||||
final gpt3 = const GPT3APIClient(apiKey: apiKey);
|
||||
|
||||
Iterable<TextNode> get currentSelectedTextNodes =>
|
||||
widget.editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
Selection? get currentSelection =>
|
||||
widget.editorState.service.selectionService.currentSelection.value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: inputEventController,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Describe how you\'d like AppFlowy to edit this text',
|
||||
),
|
||||
),
|
||||
onKey: (key) {
|
||||
if (key is! RawKeyDownEvent) return;
|
||||
if (key.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_requestGPT3EditResult();
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (result.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Result: ',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: TextField(
|
||||
controller: resultController..text = result,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText:
|
||||
'Describe how you\'d like AppFlowy to edit this text',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// replace the text
|
||||
final selection = currentSelection;
|
||||
if (selection != null) {
|
||||
assert(selection.isSingle);
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.replaceText(
|
||||
currentSelectedTextNodes.first,
|
||||
selection.startIndex,
|
||||
selection.length,
|
||||
resultController.text,
|
||||
);
|
||||
widget.editorState.apply(transaction);
|
||||
}
|
||||
},
|
||||
child: const Text('Replace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _requestGPT3EditResult() {
|
||||
final selection =
|
||||
widget.editorState.service.selectionService.currentSelection.value;
|
||||
if (selection == null || !selection.isSingle) {
|
||||
return;
|
||||
}
|
||||
final text =
|
||||
widget.editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>()
|
||||
.first
|
||||
.delta
|
||||
.slice(
|
||||
selection.startIndex,
|
||||
selection.endIndex,
|
||||
)
|
||||
.toPlainText();
|
||||
if (text.isEmpty) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 10),
|
||||
Text('Loading'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
gpt3.getGPT3Edit(
|
||||
apiKey,
|
||||
text,
|
||||
inputEventController.text,
|
||||
onResult: (result) async {
|
||||
Navigator.of(context).pop(true);
|
||||
setState(() {
|
||||
this.result = result.join('\n').trim();
|
||||
});
|
||||
},
|
||||
onError: () async {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
enum TextRobotInputType {
|
||||
character,
|
||||
word,
|
||||
}
|
||||
|
||||
class TextRobot {
|
||||
const TextRobot({
|
||||
required this.editorState,
|
||||
this.delay = const Duration(milliseconds: 30),
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Duration delay;
|
||||
|
||||
Future<void> insertText(
|
||||
String text, {
|
||||
TextRobotInputType inputType = TextRobotInputType.character,
|
||||
}) async {
|
||||
final lines = text.split('\n');
|
||||
for (final line in lines) {
|
||||
if (line.isEmpty) continue;
|
||||
switch (inputType) {
|
||||
case TextRobotInputType.character:
|
||||
final iterator = line.runes.iterator;
|
||||
while (iterator.moveNext()) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
iterator.currentAsString,
|
||||
);
|
||||
await Future.delayed(delay, () {});
|
||||
}
|
||||
break;
|
||||
case TextRobotInputType.word:
|
||||
final words = line.split(' ').map((e) => '$e ');
|
||||
for (final word in words) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
word,
|
||||
);
|
||||
await Future.delayed(delay, () {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// insert new line
|
||||
if (lines.length > 1) {
|
||||
await editorState.insertNewLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
return Theme.of(context).copyWith(
|
||||
extensions: [
|
||||
editorStyle,
|
||||
...darkPluginStyleExtension,
|
||||
quote,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.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;
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -18,7 +18,10 @@ import 'package:flutter/gestures.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
const _kRichTextDebugMode = false;
|
||||
typedef TextSpanDecoratorForCustomAttributes = InlineSpan Function(
|
||||
TextInsert attributeKey,
|
||||
TextSpan textSpan,
|
||||
);
|
||||
|
||||
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
|
||||
|
||||
|
|
@ -31,6 +34,7 @@ class FlowyRichText extends StatefulWidget {
|
|||
this.textSpanDecorator,
|
||||
this.placeholderText = ' ',
|
||||
this.placeholderTextSpanDecorator,
|
||||
this.textSpanDecoratorForCustomAttributes,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
|
@ -43,6 +47,8 @@ class FlowyRichText extends StatefulWidget {
|
|||
final FlowyTextSpanDecorator? textSpanDecorator;
|
||||
final String placeholderText;
|
||||
final FlowyTextSpanDecorator? placeholderTextSpanDecorator;
|
||||
final TextSpanDecoratorForCustomAttributes?
|
||||
textSpanDecoratorForCustomAttributes;
|
||||
|
||||
@override
|
||||
State<FlowyRichText> createState() => _FlowyRichTextState();
|
||||
|
|
@ -59,6 +65,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
_placeholderTextKey.currentContext?.findRenderObject()
|
||||
as RenderParagraph?;
|
||||
|
||||
TextSpanDecoratorForCustomAttributes?
|
||||
get textSpanDecoratorForCustomAttributes =>
|
||||
widget.textSpanDecoratorForCustomAttributes ??
|
||||
widget.editorState.editorStyle.textSpanDecorator;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowyRichText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
|
@ -235,7 +246,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
|
||||
TextSpan get _textSpan {
|
||||
var offset = 0;
|
||||
List<TextSpan> textSpans = [];
|
||||
List<InlineSpan> textSpans = [];
|
||||
final style = widget.editorState.editorStyle.textStyleConfiguration;
|
||||
final textInserts = widget.node.delta!.whereType<TextInsert>();
|
||||
for (final textInsert in textInserts) {
|
||||
|
|
@ -281,23 +292,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
}
|
||||
}
|
||||
offset += textInsert.length;
|
||||
textSpans.add(
|
||||
TextSpan(
|
||||
text: textInsert.text,
|
||||
style: textStyle,
|
||||
recognizer: recognizer,
|
||||
),
|
||||
final textSpan = TextSpan(
|
||||
text: textInsert.text,
|
||||
style: textStyle,
|
||||
recognizer: recognizer,
|
||||
);
|
||||
}
|
||||
if (_kRichTextDebugMode) {
|
||||
textSpans.add(
|
||||
TextSpan(
|
||||
text: '${widget.node.path}',
|
||||
style: const TextStyle(
|
||||
backgroundColor: Colors.red,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
),
|
||||
textSpanDecoratorForCustomAttributes != null
|
||||
? textSpanDecoratorForCustomAttributes!(textInsert, textSpan)
|
||||
: textSpan,
|
||||
);
|
||||
}
|
||||
return TextSpan(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
final Color cursorColor;
|
||||
final Color selectionColor;
|
||||
final TextStyleConfiguration textStyleConfiguration;
|
||||
final TextSpanDecoratorForCustomAttributes? textSpanDecorator;
|
||||
|
||||
// @Deprecated('customize the editor\'s background color directly')
|
||||
final Color? backgroundColor;
|
||||
|
|
@ -96,6 +97,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
required this.lineHeight,
|
||||
required this.popupMenuFGColor,
|
||||
required this.popupMenuHoverColor,
|
||||
required this.textSpanDecorator,
|
||||
});
|
||||
|
||||
const EditorStyle.desktop({
|
||||
|
|
@ -104,6 +106,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
Color? cursorColor,
|
||||
Color? selectionColor,
|
||||
TextStyleConfiguration? textStyleConfiguration,
|
||||
TextSpanDecoratorForCustomAttributes? textSpanDecorator,
|
||||
}) : this(
|
||||
padding: padding ?? const EdgeInsets.symmetric(horizontal: 100),
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
|
|
@ -114,6 +117,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
const TextStyleConfiguration(
|
||||
text: TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
textSpanDecorator: textSpanDecorator,
|
||||
selectionMenuBackgroundColor: null,
|
||||
selectionMenuItemTextColor: null,
|
||||
selectionMenuItemIconColor: null,
|
||||
|
|
@ -143,6 +147,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
Color? cursorColor,
|
||||
Color? selectionColor,
|
||||
TextStyleConfiguration? textStyleConfiguration,
|
||||
TextSpanDecoratorForCustomAttributes? textSpanDecorator,
|
||||
}) : this(
|
||||
padding: padding ?? const EdgeInsets.symmetric(horizontal: 20),
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
|
|
@ -153,6 +158,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
const TextStyleConfiguration(
|
||||
text: TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
textSpanDecorator: textSpanDecorator,
|
||||
selectionMenuBackgroundColor: null,
|
||||
selectionMenuItemTextColor: null,
|
||||
selectionMenuItemIconColor: null,
|
||||
|
|
@ -183,6 +189,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
Color? cursorColor,
|
||||
Color? selectionColor,
|
||||
TextStyleConfiguration? textStyleConfiguration,
|
||||
TextSpanDecoratorForCustomAttributes? textSpanDecorator,
|
||||
Color? selectionMenuBackgroundColor,
|
||||
Color? selectionMenuItemTextColor,
|
||||
Color? selectionMenuItemIconColor,
|
||||
|
|
@ -212,6 +219,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
selectionColor: selectionColor ?? this.selectionColor,
|
||||
textStyleConfiguration:
|
||||
textStyleConfiguration ?? this.textStyleConfiguration,
|
||||
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
|
||||
selectionMenuBackgroundColor:
|
||||
selectionMenuBackgroundColor ?? this.selectionMenuBackgroundColor,
|
||||
selectionMenuItemTextColor:
|
||||
|
|
@ -256,6 +264,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
cursorColor: Color.lerp(cursorColor, other.cursorColor, t)!,
|
||||
textPadding: EdgeInsets.lerp(textPadding, other.textPadding, t),
|
||||
textStyleConfiguration: other.textStyleConfiguration,
|
||||
textSpanDecorator: other.textSpanDecorator,
|
||||
selectionColor: Color.lerp(selectionColor, other.selectionColor, t)!,
|
||||
selectionMenuBackgroundColor: Color.lerp(
|
||||
selectionMenuBackgroundColor,
|
||||
|
|
@ -319,6 +328,7 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
|
|||
textStyleConfiguration: const TextStyleConfiguration(
|
||||
text: TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
textSpanDecorator: (_, textSpan) => textSpan,
|
||||
selectionColor: const Color.fromARGB(53, 111, 201, 231),
|
||||
selectionMenuBackgroundColor: const Color(0xFFFFFFFF),
|
||||
selectionMenuItemTextColor: const Color(0xFF333333),
|
||||
|
|
|
|||
88
test/customer/custom_attribute_for_text_block_test.dart
Normal file
88
test/customer/custom_attribute_for_text_block_test.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../new/util/util.dart';
|
||||
|
||||
void main() async {
|
||||
// column
|
||||
// - text field
|
||||
// - editor
|
||||
|
||||
// 1. When the text field is focused, the editor's cursor should be disabled.
|
||||
// 2. When the editor is focused, the text field's cursor should be disabled.
|
||||
// 3. Tapping a non-first line of the editor should still allow the editor to grab focus.
|
||||
testWidgets('text field + editor', (tester) async {
|
||||
const widget = CustomAttributeKeyForTextBlock();
|
||||
await tester.pumpWidget(widget);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.edit_document), findsOneWidget);
|
||||
expect(find.textContaining('PAGE_ID'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class CustomAttributeKeyForTextBlock extends StatelessWidget {
|
||||
const CustomAttributeKeyForTextBlock({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final editorStyle = EditorStyle.desktop(
|
||||
// Example for customizing a new attribute key.
|
||||
textSpanDecorator: (textInsert, textSpan) {
|
||||
final attributes = textInsert.attributes;
|
||||
if (attributes == null) {
|
||||
return textSpan;
|
||||
}
|
||||
final mention = attributes['mention'] as Map?;
|
||||
if (mention != null) {
|
||||
return WidgetSpan(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.edit_document),
|
||||
Text(mention['id']),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return textSpan;
|
||||
},
|
||||
);
|
||||
final document = Document.blank()
|
||||
..addParagraph(
|
||||
builder: (index) {
|
||||
return Delta()
|
||||
..insert(
|
||||
'\$',
|
||||
attributes: {
|
||||
'mention': {'id': 'PAGE_ID'},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
final editorState = EditorState(document: document);
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Container(
|
||||
width: 500,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.blue),
|
||||
),
|
||||
child: AppFlowyEditor.standard(
|
||||
editorState: editorState,
|
||||
editorStyle: editorStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue