feat: support customizing text attribute key and rendering (#244)

This commit is contained in:
Lucas.Xu 2023-06-23 18:36:33 +08:00 • committed by GitHub
commit d359da89c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 187 additions and 1012 deletions

View file

@ -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,
),
);
}
}

View file

@ -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,
],
);
}
}

View file

@ -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,

View file

@ -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,
);

View file

@ -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();
}
},
),
);
},
);
}),
);

View file

@ -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);
},
);
}),
);

View file

@ -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();
}
}
}

View file

@ -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);
},
);
}
}

View file

@ -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();
}
}
}
}

View file

@ -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,
],
);
}

View file

@ -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);
}

View file

@ -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(

View file

@ -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),

View 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,
),
),
),
),
);
}
}