chore: update UIs in document and move plugins out of package (#2289)

* chore: move plugins out of package

* chore: update cover image picker style

* chore: update math equation style

* chore: rename barrel file

* chore: add LocaleKeys
This commit is contained in:
Yijing Huang
2023-04-19 17:56:02 -05:00
committed by GitHub
parent 782d01274d
commit 40e266f5ce
38 changed files with 152 additions and 467 deletions

View File

@ -1,15 +1,6 @@
import 'package:appflowy/plugins/document/presentation/plugins/board/board_view_menu_item.dart';
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_menu_item.dart';
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -20,8 +11,6 @@ import '../../startup/startup.dart';
import 'application/doc_bloc.dart';
import 'editor_styles.dart';
import 'presentation/banner.dart';
import 'presentation/plugins/grid/grid_view_menu_item.dart';
import 'presentation/plugins/board/board_menu_item.dart';
class DocumentPage extends StatefulWidget {
final VoidCallback onDeleted;

View File

@ -0,0 +1,300 @@
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
const String kCalloutType = 'callout';
const String kCalloutAttrColor = 'color';
const String kCalloutAttrEmoji = 'emoji';
SelectionMenuItem calloutMenuItem = SelectionMenuItem.node(
name: 'Callout',
iconData: Icons.note,
keywords: ['callout'],
nodeBuilder: (editorState) {
final node = Node(type: kCalloutType);
node.insert(TextNode.empty());
return node;
},
replace: (_, textNode) => textNode.toPlainText().isEmpty,
updateSelection: (_, path, __, ___) {
return Selection.single(path: [...path, 0], startOffset: 0);
},
);
class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _CalloutWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) => node.type == kCalloutType;
_CalloutWidgetState? _getState(NodeWidgetContext<Node> context) {
return context.node.key.currentState as _CalloutWidgetState?;
}
BuildContext? _getBuildContext(NodeWidgetContext<Node> context) {
return context.node.key.currentContext;
}
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.icon(
iconData: Icons.color_lens_outlined,
onPressed: () {
final state = _getState(context);
final ctx = _getBuildContext(context);
if (state == null || ctx == null) {
return;
}
final menuState = Provider.of<ActionMenuState>(ctx, listen: false);
menuState.isPinned = true;
state.colorPopoverController.show();
},
itemWrapper: (item) {
final state = _getState(context);
final ctx = _getBuildContext(context);
if (state == null || ctx == null) {
return item;
}
return AppFlowyPopover(
controller: state.colorPopoverController,
popupBuilder: (context) => state._buildColorPicker(),
constraints: BoxConstraints.loose(const Size(200, 460)),
triggerActions: 0,
offset: const Offset(0, 30),
child: item,
onClose: () {
final menuState =
Provider.of<ActionMenuState>(ctx, listen: false);
menuState.isPinned = false;
},
);
},
),
ActionMenuItem.svg(
name: 'delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _CalloutWidget extends StatefulWidget {
const _CalloutWidget({
super.key,
required this.node,
required this.editorState,
});
final Node node;
final EditorState editorState;
@override
State<_CalloutWidget> createState() => _CalloutWidgetState();
}
class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
final PopoverController colorPopoverController = PopoverController();
final PopoverController emojiPopoverController = PopoverController();
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
void initState() {
widget.node.addListener(nodeChanged);
super.initState();
}
@override
void dispose() {
widget.node.removeListener(nodeChanged);
super.dispose();
}
void nodeChanged() {
if (widget.node.children.isEmpty) {
deleteNode();
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: tint.color(context),
),
padding: const EdgeInsets.only(top: 8, bottom: 8, left: 0, right: 15),
width: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEmoji(),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.node.children
.map(
(child) => widget.editorState.service.renderPluginService
.buildPluginWidget(
child is TextNode
? NodeWidgetContext<TextNode>(
context: context,
node: child,
editorState: widget.editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
),
),
],
),
);
}
Widget _popover({
required PopoverController controller,
required Widget Function(BuildContext context) popupBuilder,
required Widget child,
Size size = const Size(200, 460),
}) {
return AppFlowyPopover(
controller: controller,
constraints: BoxConstraints.loose(size),
triggerActions: 0,
popupBuilder: popupBuilder,
child: child,
);
}
Widget _buildColorPicker() {
return FlowyColorPicker(
colors: FlowyTint.values
.map(
(t) => ColorOption(
color: t.color(context),
name: t.tintName(AppFlowyEditorLocalizations.current),
),
)
.toList(),
selected: tint.color(context),
onTap: (color, index) {
setColor(FlowyTint.values[index]);
colorPopoverController.close();
},
);
}
Widget _buildEmoji() {
return _popover(
controller: emojiPopoverController,
popupBuilder: (context) => _buildEmojiPicker(),
size: const Size(300, 200),
child: FlowyTextButton(
emoji,
fontSize: 18,
fillColor: Colors.transparent,
onPressed: () {
emojiPopoverController.show();
},
),
);
}
Widget _buildEmojiPicker() {
return EmojiSelectionMenu(
editorState: widget.editorState,
onSubmitted: (emoji) {
setEmoji(emoji.emoji);
emojiPopoverController.close();
},
onExit: () {},
);
}
void setColor(FlowyTint tint) {
final transaction = widget.editorState.transaction
..updateNode(widget.node, {
kCalloutAttrColor: tint.name,
});
widget.editorState.apply(transaction);
}
void setEmoji(String emoji) {
final transaction = widget.editorState.transaction
..updateNode(widget.node, {
kCalloutAttrEmoji: emoji,
});
widget.editorState.apply(transaction);
}
void deleteNode() {
final transaction = widget.editorState.transaction..deleteNode(widget.node);
widget.editorState.apply(transaction);
}
FlowyTint get tint {
final name = widget.node.attributes[kCalloutAttrColor];
return (name is String) ? FlowyTint.fromJson(name) : FlowyTint.tint1;
}
String get emoji {
return widget.node.attributes[kCalloutAttrEmoji] ?? "💡";
}
@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
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.borderLine;
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@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

@ -0,0 +1,224 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
import 'package:highlight/languages/all.dart';
const String kCodeBlockType = 'text/$kCodeBlockSubType';
const String kCodeBlockSubType = 'code_block';
const String kCodeBlockAttrTheme = 'theme';
const String kCodeBlockAttrLanguage = 'language';
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode>
with ActionProvider<TextNode> {
@override
Widget build(NodeWidgetContext<TextNode> context) {
return _CodeBlockNodeWidge(
key: context.node.key,
textNode: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node is TextNode &&
node.attributes[kCodeBlockAttrTheme] is String;
};
@override
List<ActionMenuItem> actions(NodeWidgetContext<TextNode> context) {
return [
ActionMenuItem.svg(
name: 'delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _CodeBlockNodeWidge extends StatefulWidget {
const _CodeBlockNodeWidge({
Key? key,
required this.textNode,
required this.editorState,
}) : super(key: key);
final TextNode textNode;
final EditorState editorState;
@override
State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
}
class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
with SelectableMixin, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: kCodeBlockType);
final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30);
String? get _language =>
widget.textNode.attributes[kCodeBlockAttrLanguage] as String?;
String? _detectLanguage;
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
GlobalKey<State<StatefulWidget>>? get iconKey => null;
@override
Offset get baseOffset => super.baseOffset + _padding.topLeft;
@override
Widget build(BuildContext context) {
return Stack(
children: [
_buildCodeBlock(context),
_buildSwitchCodeButton(context),
],
);
}
Widget _buildCodeBlock(BuildContext context) {
final result = highlight.highlight.parse(
widget.textNode.toPlainText(),
language: _language,
autoDetection: _language == null,
);
_detectLanguage = _language ?? result.language;
final code = result.nodes;
final codeTextSpan = _convert(code!);
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.withOpacity(0.1),
),
padding: _padding,
width: MediaQuery.of(context).size.width,
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
lineHeight: 1.0,
cursorHeight: 15.0,
textSpanDecorator: (textSpan) => TextSpan(
style: widget.editorState.editorStyle.textStyle,
children: codeTextSpan,
),
),
);
}
Widget _buildSwitchCodeButton(BuildContext context) {
return Positioned(
top: -5,
left: 10,
child: SizedBox(
height: 35,
child: DropdownButton<String>(
value: _detectLanguage,
iconSize: 14.0,
onChanged: (value) {
final transaction = widget.editorState.transaction
..updateNode(widget.textNode, {
kCodeBlockAttrLanguage: value,
});
widget.editorState.apply(transaction);
},
items:
allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: FlowyText.medium(
value,
color: Theme.of(context).colorScheme.tertiary,
),
);
}).toList(growable: false),
),
),
);
}
// Copy from flutter.highlight package.
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
List<TextSpan> _convert(List<highlight.Node> nodes) {
List<TextSpan> spans = [];
var currentSpans = spans;
List<List<TextSpan>> stack = [];
void traverse(highlight.Node node) {
if (node.value != null) {
currentSpans.add(node.className == null
? TextSpan(text: node.value)
: TextSpan(
text: node.value,
style: _builtInCodeBlockTheme[node.className!],),);
} else if (node.children != null) {
List<TextSpan> tmp = [];
currentSpans.add(TextSpan(
children: tmp, style: _builtInCodeBlockTheme[node.className!],),);
stack.add(currentSpans);
currentSpans = tmp;
for (var n in node.children!) {
traverse(n);
if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast();
}
}
}
}
for (var node in nodes) {
traverse(node);
}
return spans;
}
}
const _builtInCodeBlockTheme = {
'root':
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
'comment': TextStyle(color: Color(0xff007400)),
'quote': TextStyle(color: Color(0xff007400)),
'tag': TextStyle(color: Color(0xffaa0d91)),
'attribute': TextStyle(color: Color(0xffaa0d91)),
'keyword': TextStyle(color: Color(0xffaa0d91)),
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
'literal': TextStyle(color: Color(0xffaa0d91)),
'name': TextStyle(color: Color(0xffaa0d91)),
'variable': TextStyle(color: Color(0xff3F6E74)),
'template-variable': TextStyle(color: Color(0xff3F6E74)),
'code': TextStyle(color: Color(0xffc41a16)),
'string': TextStyle(color: Color(0xffc41a16)),
'meta-string': TextStyle(color: Color(0xffc41a16)),
'regexp': TextStyle(color: Color(0xff0E0EFF)),
'link': TextStyle(color: Color(0xff0E0EFF)),
'title': TextStyle(color: Color(0xff1c00cf)),
'symbol': TextStyle(color: Color(0xff1c00cf)),
'bullet': TextStyle(color: Color(0xff1c00cf)),
'number': TextStyle(color: Color(0xff1c00cf)),
'section': TextStyle(color: Color(0xff643820)),
'meta': TextStyle(color: Color(0xff643820)),
'type': TextStyle(color: Color(0xff5c2699)),
'built_in': TextStyle(color: Color(0xff5c2699)),
'builtin-name': TextStyle(color: Color(0xff5c2699)),
'params': TextStyle(color: Color(0xff5c2699)),
'attr': TextStyle(color: Color(0xff836C28)),
'subst': TextStyle(color: Color(0xff000000)),
'formula': TextStyle(
backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic,),
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
'selector-id': TextStyle(color: Color(0xff9b703f)),
'selector-class': TextStyle(color: Color(0xff9b703f)),
'doctag': TextStyle(fontWeight: FontWeight.bold),
'strong': TextStyle(fontWeight: FontWeight.bold),
'emphasis': TextStyle(fontStyle: FontStyle.italic),
};

View File

@ -0,0 +1,125 @@
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
ShortcutEvent enterInCodeBlock = ShortcutEvent(
key: 'Press Enter In Code Block',
command: 'enter',
handler: _enterInCodeBlockHandler,
);
ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
key: 'White space in code block',
command: 'space, slash, shift+underscore',
handler: _ignorekHandler,
);
ShortcutEvent pasteInCodeBlock = ShortcutEvent(
key: 'Paste in code block',
command: 'meta+v',
windowsCommand: 'ctrl+v',
linuxCommand: 'ctrl+v',
handler: _pasteHandler,
);
ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNode =
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
if (codeBlockNode.length != 1 ||
selection == null ||
!selection.isCollapsed) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction
..insertText(
codeBlockNode.first,
selection.end.offset,
'\n',
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
ShortcutEventHandler _ignorekHandler = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNodes =
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
if (codeBlockNodes.length == 1) {
return KeyEventResult.skipRemainingHandlers;
}
return KeyEventResult.ignored;
};
ShortcutEventHandler _pasteHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNodes =
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
if (selection != null &&
selection.isCollapsed &&
codeBlockNodes.length == 1) {
Clipboard.getData(Clipboard.kTextPlain).then((value) {
final text = value?.text;
if (text == null) return;
final transaction = editorState.transaction;
transaction.insertText(
codeBlockNodes.first,
selection.startIndex,
text,
);
editorState.apply(transaction);
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
name: 'Code Block',
icon: (editorState, onSelected) => Icon(
Icons.abc,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['code block', 'code snippet'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final transaction = editorState.transaction;
final textNode = textNodes.first;
if (textNode.toPlainText().isEmpty && textNode.next is TextNode) {
transaction.updateNode(textNodes.first, {
BuiltInAttributeKey.subtype: kCodeBlockSubType,
kCodeBlockAttrTheme: 'vs',
kCodeBlockAttrLanguage: null,
});
transaction.afterSelection = selection;
editorState.apply(transaction);
} else {
transaction.insertNode(
selection.end.path,
TextNode(
attributes: {
BuiltInAttributeKey.subtype: kCodeBlockSubType,
kCodeBlockAttrTheme: 'vs',
kCodeBlockAttrLanguage: null,
},
delta: Delta()..insert('\n'),
),
);
transaction.afterSelection = selection;
}
editorState.apply(transaction);
},
);

View File

@ -2,11 +2,8 @@ import 'dart:io';
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_image_picker.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/size.dart';
@ -257,8 +254,6 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
if (index == 0) {
return Container(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.primary.withOpacity(0.15),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
@ -270,6 +265,8 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
hoverColor:
Theme.of(context).colorScheme.primary.withOpacity(0.15),
width: 20,
onPressed: () {
setState(() {

View File

@ -145,7 +145,7 @@ class _NetworkImageUrlInputState extends State<NetworkImageUrlInput> {
},
hoverColor: Colors.transparent,
fillColor: buttonDisabled
? Colors.grey
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.primary,
height: 36,
title: LocaleKeys.document_plugins_cover_add.tr(),
@ -174,7 +174,7 @@ class ImagePickerActionButtons extends StatelessWidget {
children: [
FlowyTextButton(
LocaleKeys.document_plugins_cover_back.tr(),
hoverColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.end,
onPressed: () => onBackPressed(),
@ -182,7 +182,7 @@ class ImagePickerActionButtons extends StatelessWidget {
FlowyTextButton(
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
onPressed: () => onSave(),
hoverColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.end,
fontColor: Theme.of(context).colorScheme.primary,
@ -204,48 +204,61 @@ class CoverImagePreviewWidget extends StatefulWidget {
class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
_buildFilePickerWidget(BuildContext ctx) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
svgWidget(
"editor/add",
size: const Size(20, 20),
),
const SizedBox(
width: 3,
),
FlowyText(
LocaleKeys.document_plugins_cover_pasteImageUrl.tr(),
),
],
),
const SizedBox(
height: 10,
),
FlowyText(
LocaleKeys.document_plugins_cover_or.tr(),
color: Colors.grey,
),
const SizedBox(
height: 10,
),
FlowyButton(
onTap: () {
ctx.read<CoverImagePickerBloc>().add(const PickFileImage());
},
useIntrinsicWidth: true,
leftIcon: svgWidget(
"file_icon",
size: const Size(25, 25),
),
text: FlowyText(
LocaleKeys.document_plugins_cover_pickFromFiles.tr(),
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: Corners.s6Border,
border: Border.fromBorderSide(
BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 1,
),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
name: 'editor/add',
size: Size(20, 20),
),
const SizedBox(
width: 3,
),
FlowyText(
LocaleKeys.document_plugins_cover_pasteImageUrl.tr(),
),
],
),
const SizedBox(
height: 10,
),
FlowyText(
LocaleKeys.document_plugins_cover_or.tr(),
fontWeight: FontWeight.w300,
),
const SizedBox(
height: 10,
),
FlowyButton(
hoverColor: Theme.of(context).hoverColor,
onTap: () {
ctx.read<CoverImagePickerBloc>().add(const PickFileImage());
},
useIntrinsicWidth: true,
leftIcon: const FlowySvg(
name: 'file_icon',
size: Size(20, 20),
),
text: FlowyText(
LocaleKeys.document_plugins_cover_pickFromFiles.tr(),
),
),
],
),
);
}

View File

@ -0,0 +1,84 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const String kDividerType = 'divider';
class DividerWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _DividerWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return true;
};
}
class _DividerWidget extends StatefulWidget {
const _DividerWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_DividerWidget> createState() => _DividerWidgetState();
}
class _DividerWidgetState extends State<_DividerWidget> with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Container(
height: 1,
color: Colors.grey,
),
);
}
@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
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.borderLine;
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@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

@ -0,0 +1,72 @@
import 'package:appflowy/plugins/document/presentation/plugins/divider/divider_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
// insert divider into a document by typing three minuses.
// ---
ShortcutEvent insertDividerEvent = ShortcutEvent(
key: 'Divider',
command: 'Minus',
handler: _insertDividerHandler,
);
ShortcutEventHandler _insertDividerHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
if (textNode.toPlainText() != '--') {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction
..deleteText(textNode, 0, 2) // remove the existing minuses.
..insertNode(textNode.path, Node(type: kDividerType)) // insert the divder
..afterSelection = Selection.single(
// update selection to the next text node.
path: textNode.path.next,
startOffset: 0,
);
editorState.apply(transaction);
return KeyEventResult.handled;
};
SelectionMenuItem dividerMenuItem = SelectionMenuItem(
name: 'Divider',
icon: (editorState, onSelected) => Icon(
Icons.horizontal_rule,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['horizontal rule', 'divider'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null) {
return;
}
final textNode = textNodes.first;
// insert the divider at current path if the text node is empty.
if (textNode.toPlainText().isEmpty) {
final transaction = editorState.transaction
..insertNode(textNode.path, Node(type: kDividerType))
..afterSelection = Selection.single(
path: textNode.path.next,
startOffset: 0,
);
editorState.apply(transaction);
} else {
// insert the divider at the path next to current path if the text node is not empty.
final transaction = editorState.transaction
..insertNode(selection.end.path.next, Node(type: kDividerType))
..afterSelection = selection;
editorState.apply(transaction);
}
},
);

View File

@ -0,0 +1,176 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'emoji_picker.dart';
SelectionMenuItem emojiMenuItem = SelectionMenuItem(
name: 'Emoji',
icon: (editorState, onSelected) => Icon(
Icons.emoji_emotions_outlined,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['emoji'],
handler: _showEmojiSelectionMenu,
);
OverlayEntry? _emojiSelectionMenu;
EditorState? _editorState;
void _showEmojiSelectionMenu(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) {
final alignment = menuService.alignment;
final offset = menuService.offset;
menuService.dismiss();
_emojiSelectionMenu?.remove();
_emojiSelectionMenu = OverlayEntry(builder: (context) {
return Positioned(
top: alignment == Alignment.bottomLeft ? offset.dy : null,
bottom: alignment == Alignment.topLeft ? offset.dy : null,
left: offset.dx,
child: Material(
child: EmojiSelectionMenu(
editorState: editorState,
onSubmitted: (text) {
// insert emoji
editorState.insertEmoji(text);
},
onExit: () {
_dismissEmojiSelectionMenu();
//close emoji panel
},
),
),
);
},);
Overlay.of(context).insert(_emojiSelectionMenu!);
editorState.service.selectionService.currentSelection
.addListener(_dismissEmojiSelectionMenu);
}
void _dismissEmojiSelectionMenu() {
_emojiSelectionMenu?.remove();
_emojiSelectionMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissEmojiSelectionMenu);
_editorState = null;
}
class EmojiSelectionMenu extends StatefulWidget {
const EmojiSelectionMenu({
Key? key,
required this.onSubmitted,
required this.onExit,
required this.editorState,
}) : super(key: key);
final void Function(Emoji emoji) onSubmitted;
final void Function() onExit;
final EditorState editorState;
@override
State<EmojiSelectionMenu> createState() => _EmojiSelectionMenuState();
}
class _EmojiSelectionMenuState extends State<EmojiSelectionMenu> {
EditorStyle get style => widget.editorState.editorStyle;
@override
void initState() {
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
super.initState();
}
bool _handleGlobalKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.escape &&
event is KeyDownEvent) {
//triggers on esc
widget.onExit();
return true;
} else {
return false;
}
}
@override
void deactivate() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
super.deactivate();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: style.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _buildEmojiBox(context),
);
}
Widget _buildEmojiBox(BuildContext context) {
return SizedBox(
height: 200,
child: EmojiPicker(
onEmojiSelected: (category, emoji) => widget.onSubmitted(emoji),
config: Config(
columns: 8,
emojiSizeMax: 28,
bgColor:
style.selectionMenuBackgroundColor ?? const Color(0xffF2F2F2),
iconColor: Colors.grey,
iconColorSelected: const Color(0xff333333),
indicatorColor: const Color(0xff333333),
progressIndicatorColor: const Color(0xff333333),
buttonMode: ButtonMode.CUPERTINO,
initCategory: Category.RECENT,
),
),
);
}
}
extension on EditorState {
void insertEmoji(Emoji emoji) {
final selectionService = service.selectionService;
final currentSelection = selectionService.currentSelection.value;
final nodes = selectionService.currentSelectedNodes;
if (currentSelection == null ||
!currentSelection.isCollapsed ||
nodes.first is! TextNode) {
return;
}
final textNode = nodes.first as TextNode;
final tr = transaction;
tr.insertText(
textNode,
currentSelection.endIndex,
emoji.emoji,
);
apply(tr);
}
}

View File

@ -0,0 +1,4 @@
export 'src/config.dart';
export 'src/emoji_picker.dart';
export 'src/emoji_picker_builder.dart';
export 'src/models/emoji_model.dart';

View File

@ -0,0 +1,165 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'models/category_models.dart';
import 'emoji_picker.dart';
/// Config for customizations
class Config {
/// Constructor
const Config(
{this.columns = 7,
this.emojiSizeMax = 32.0,
this.verticalSpacing = 0,
this.horizontalSpacing = 0,
this.initCategory = Category.RECENT,
this.bgColor = const Color(0xFFEBEFF2),
this.indicatorColor = Colors.blue,
this.iconColor = Colors.grey,
this.iconColorSelected = Colors.blue,
this.progressIndicatorColor = Colors.blue,
this.backspaceColor = Colors.blue,
this.showRecentsTab = true,
this.recentsLimit = 28,
this.noRecentsText = 'No Recents',
this.noRecentsStyle =
const TextStyle(fontSize: 20, color: Colors.black26),
this.tabIndicatorAnimDuration = kTabScrollDuration,
this.categoryIcons = const CategoryIcons(),
this.buttonMode = ButtonMode.MATERIAL,});
/// Number of emojis per row
final int columns;
/// Width and height the emoji will be maximal displayed
/// Can be smaller due to screen size and amount of columns
final double emojiSizeMax;
/// Vertical spacing between emojis
final double verticalSpacing;
/// Horizontal spacing between emojis
final double horizontalSpacing;
/// The initial [Category] that will be selected
/// This [Category] will have its button in the bottombar darkened
final Category initCategory;
/// The background color of the Widget
final Color bgColor;
/// The color of the category indicator
final Color indicatorColor;
/// The color of the category icons
final Color iconColor;
/// The color of the category icon when selected
final Color iconColorSelected;
/// The color of the loading indicator during initialization
final Color progressIndicatorColor;
/// The color of the backspace icon button
final Color backspaceColor;
/// Show extra tab with recently used emoji
final bool showRecentsTab;
/// Limit of recently used emoji that will be saved
final int recentsLimit;
/// The text to be displayed if no recent emojis to display
final String noRecentsText;
/// The text style for [noRecentsText]
final TextStyle noRecentsStyle;
/// Duration of tab indicator to animate to next category
final Duration tabIndicatorAnimDuration;
/// Determines the icon to display for each [Category]
final CategoryIcons categoryIcons;
/// Change between Material and Cupertino button style
final ButtonMode buttonMode;
/// Get Emoji size based on properties and screen width
double getEmojiSize(double width) {
final maxSize = width / columns;
return min(maxSize, emojiSizeMax);
}
/// Returns the icon for the category
IconData getIconForCategory(Category category) {
switch (category) {
case Category.RECENT:
return categoryIcons.recentIcon;
case Category.SMILEYS:
return categoryIcons.smileyIcon;
case Category.ANIMALS:
return categoryIcons.animalIcon;
case Category.FOODS:
return categoryIcons.foodIcon;
case Category.TRAVEL:
return categoryIcons.travelIcon;
case Category.ACTIVITIES:
return categoryIcons.activityIcon;
case Category.OBJECTS:
return categoryIcons.objectIcon;
case Category.SYMBOLS:
return categoryIcons.symbolIcon;
case Category.FLAGS:
return categoryIcons.flagIcon;
case Category.SEARCH:
return categoryIcons.searchIcon;
default:
throw Exception('Unsupported Category');
}
}
@override
bool operator ==(other) {
return (other is Config) &&
other.columns == columns &&
other.emojiSizeMax == emojiSizeMax &&
other.verticalSpacing == verticalSpacing &&
other.horizontalSpacing == horizontalSpacing &&
other.initCategory == initCategory &&
other.bgColor == bgColor &&
other.indicatorColor == indicatorColor &&
other.iconColor == iconColor &&
other.iconColorSelected == iconColorSelected &&
other.progressIndicatorColor == progressIndicatorColor &&
other.backspaceColor == backspaceColor &&
other.showRecentsTab == showRecentsTab &&
other.recentsLimit == recentsLimit &&
other.noRecentsText == noRecentsText &&
other.noRecentsStyle == noRecentsStyle &&
other.tabIndicatorAnimDuration == tabIndicatorAnimDuration &&
other.categoryIcons == categoryIcons &&
other.buttonMode == buttonMode;
}
@override
int get hashCode =>
columns.hashCode ^
emojiSizeMax.hashCode ^
verticalSpacing.hashCode ^
horizontalSpacing.hashCode ^
initCategory.hashCode ^
bgColor.hashCode ^
indicatorColor.hashCode ^
iconColor.hashCode ^
iconColorSelected.hashCode ^
progressIndicatorColor.hashCode ^
backspaceColor.hashCode ^
showRecentsTab.hashCode ^
recentsLimit.hashCode ^
noRecentsText.hashCode ^
noRecentsStyle.hashCode ^
tabIndicatorAnimDuration.hashCode ^
categoryIcons.hashCode ^
buttonMode.hashCode;
}

View File

@ -0,0 +1,293 @@
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
import 'package:flutter/material.dart';
import 'config.dart';
import 'emoji_picker.dart';
import 'emoji_picker_builder.dart';
import 'emoji_view_state.dart';
import 'models/category_models.dart';
import 'models/emoji_model.dart';
class DefaultEmojiPickerView extends EmojiPickerBuilder {
const DefaultEmojiPickerView(Config config, EmojiViewState state, {Key? key})
: super(config, state, key: key);
@override
DefaultEmojiPickerViewState createState() => DefaultEmojiPickerViewState();
}
class DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
with TickerProviderStateMixin {
PageController? _pageController;
TabController? _tabController;
final TextEditingController _emojiController = TextEditingController();
final FocusNode _emojiFocusNode = FocusNode();
CategoryEmoji searchEmojiList = CategoryEmoji(Category.SEARCH, <Emoji>[]);
@override
void initState() {
var initCategory = widget.state.categoryEmoji.indexWhere(
(element) => element.category == widget.config.initCategory,);
if (initCategory == -1) {
initCategory = 0;
}
_tabController = TabController(
initialIndex: initCategory,
length: widget.state.categoryEmoji.length,
vsync: this,);
_pageController = PageController(initialPage: initCategory);
_emojiFocusNode.requestFocus();
_emojiController.addListener(() {
String query = _emojiController.text.toLowerCase();
if (query.isEmpty) {
searchEmojiList.emoji.clear();
_pageController!.jumpToPage(
_tabController!.index,
);
} else {
searchEmojiList.emoji.clear();
for (var element in widget.state.categoryEmoji) {
searchEmojiList.emoji.addAll(
element.emoji.where((item) {
return item.name.toLowerCase().contains(query);
}).toList(),
);
}
}
setState(() {});
});
super.initState();
}
@override
void dispose() {
_emojiController.dispose();
_emojiFocusNode.dispose();
super.dispose();
}
Widget _buildBackspaceButton() {
if (widget.state.onBackspacePressed != null) {
return Material(
type: MaterialType.transparency,
child: IconButton(
padding: const EdgeInsets.only(bottom: 2),
icon: Icon(
Icons.backspace,
color: widget.config.backspaceColor,
),
onPressed: () {
widget.state.onBackspacePressed!();
},),
);
}
return Container();
}
bool isEmojiSearching() {
bool result =
searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty;
return result;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final emojiSize = widget.config.getEmojiSize(constraints.maxWidth);
return Container(
color: widget.config.bgColor,
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
SizedBox(
height: 25.0,
child: TextField(
controller: _emojiController,
focusNode: _emojiFocusNode,
autofocus: true,
style: const TextStyle(fontSize: 14.0),
cursorWidth: 1.0,
cursorColor: Colors.black,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 5.0),
hintText: "Search emoji",
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
borderSide: const BorderSide(),
gapPadding: 0.0,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
borderSide: const BorderSide(),
gapPadding: 0.0,
),
filled: true,
fillColor: Colors.white,
hoverColor: Colors.white,
),
),
),
Row(
children: [
Expanded(
child: TabBar(
labelColor: widget.config.iconColorSelected,
unselectedLabelColor: widget.config.iconColor,
controller: isEmojiSearching()
? TabController(length: 1, vsync: this)
: _tabController,
labelPadding: EdgeInsets.zero,
indicatorColor: widget.config.indicatorColor,
padding: const EdgeInsets.symmetric(vertical: 5.0),
indicator: BoxDecoration(
border: Border.all(color: Colors.transparent),
borderRadius: BorderRadius.circular(4.0),
color: Colors.grey.withOpacity(0.5),
),
onTap: (index) {
_pageController!.animateToPage(
index,
duration: widget.config.tabIndicatorAnimDuration,
curve: Curves.ease,
);
},
tabs: isEmojiSearching()
? [_buildCategory(Category.SEARCH, emojiSize)]
: widget.state.categoryEmoji
.asMap()
.entries
.map<Widget>((item) => _buildCategory(
item.value.category, emojiSize,),)
.toList(),
),
),
_buildBackspaceButton(),
],
),
Flexible(
child: PageView.builder(
itemCount: searchEmojiList.emoji.isNotEmpty
? 1
: widget.state.categoryEmoji.length,
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
// onPageChanged: (index) {
// _tabController!.animateTo(
// index,
// duration: widget.config.tabIndicatorAnimDuration,
// );
// },
itemBuilder: (context, index) {
CategoryEmoji catEmoji = isEmojiSearching()
? searchEmojiList
: widget.state.categoryEmoji[index];
return _buildPage(emojiSize, catEmoji);
},
),
),
],
),
);
},
);
}
Widget _buildCategory(Category category, double categorySize) {
return Tab(
height: categorySize,
child: Icon(
widget.config.getIconForCategory(category),
size: categorySize / 1.3,
),
);
}
Widget _buildButtonWidget(
{required VoidCallback onPressed, required Widget child,}) {
if (widget.config.buttonMode == ButtonMode.MATERIAL) {
return InkWell(
onTap: onPressed,
child: child,
);
}
return GestureDetector(
onTap: onPressed,
child: child,
);
}
Widget _buildPage(double emojiSize, CategoryEmoji categoryEmoji) {
// Display notice if recent has no entries yet
final scrollController = ScrollController();
if (categoryEmoji.category == Category.RECENT &&
categoryEmoji.emoji.isEmpty) {
return _buildNoRecent();
} else if (categoryEmoji.category == Category.SEARCH &&
categoryEmoji.emoji.isEmpty) {
return const Center(child: Text("No Emoji Found"));
}
// Build page normally
return ScrollbarListStack(
axis: Axis.vertical,
controller: scrollController,
barSize: 4.0,
scrollbarPadding: const EdgeInsets.symmetric(horizontal: 5.0),
handleColor: const Color(0xffDFE0E0),
trackColor: const Color(0xffDFE0E0),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: GridView.builder(
controller: scrollController,
padding: const EdgeInsets.all(0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.config.columns,
mainAxisSpacing: widget.config.verticalSpacing,
crossAxisSpacing: widget.config.horizontalSpacing,
),
itemCount: categoryEmoji.emoji.length,
itemBuilder: (context, index) {
final item = categoryEmoji.emoji[index];
return _buildEmoji(emojiSize, categoryEmoji, item);
},
cacheExtent: 10,
),
),
);
}
Widget _buildEmoji(
double emojiSize,
CategoryEmoji categoryEmoji,
Emoji emoji,
) {
return _buildButtonWidget(
onPressed: () {
widget.state.onEmojiSelected(categoryEmoji.category, emoji);
},
child: FittedBox(
fit: BoxFit.fill,
child: Text(
emoji.emoji,
textScaleFactor: 1.0,
style: TextStyle(
fontSize: emojiSize,
backgroundColor: Colors.transparent,
),
),
),);
}
Widget _buildNoRecent() {
return Center(
child: Text(
widget.config.noRecentsText,
style: widget.config.noRecentsStyle,
textAlign: TextAlign.center,
),);
}
}

View File

@ -0,0 +1,312 @@
// ignore_for_file: constant_identifier_names
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/category_models.dart';
import 'config.dart';
import 'default_emoji_picker_view.dart';
import 'models/emoji_model.dart';
import 'emoji_lists.dart' as emoji_list;
import 'emoji_view_state.dart';
import 'models/recent_emoji_model.dart';
/// All the possible categories that [Emoji] can be put into
///
/// All [Category] are shown in the category bar
enum Category {
/// Searched emojis
SEARCH,
/// Recent emojis
RECENT,
/// Smiley emojis
SMILEYS,
/// Animal emojis
ANIMALS,
/// Food emojis
FOODS,
/// Activity emojis
ACTIVITIES,
/// Travel emojis
TRAVEL,
/// Objects emojis
OBJECTS,
/// Sumbol emojis
SYMBOLS,
/// Flag emojis
FLAGS,
}
/// Enum to alter the keyboard button style
enum ButtonMode {
/// Android button style - gives the button a splash color with ripple effect
MATERIAL,
/// iOS button style - gives the button a fade out effect when pressed
CUPERTINO
}
/// Callback function for when emoji is selected
///
/// The function returns the selected [Emoji] as well
/// as the [Category] from which it originated
typedef OnEmojiSelected = void Function(Category category, Emoji emoji);
/// Callback function for backspace button
typedef OnBackspacePressed = void Function();
/// Callback function for custom view
typedef EmojiViewBuilder = Widget Function(Config config, EmojiViewState state);
/// The Emoji Keyboard widget
///
/// This widget displays a grid of [Emoji] sorted by [Category]
/// which the user can horizontally scroll through.
///
/// There is also a bottombar which displays all the possible [Category]
/// and allow the user to quickly switch to that [Category]
class EmojiPicker extends StatefulWidget {
/// EmojiPicker for flutter
const EmojiPicker({
Key? key,
required this.onEmojiSelected,
this.onBackspacePressed,
this.config = const Config(),
this.customWidget,
}) : super(key: key);
/// Custom widget
final EmojiViewBuilder? customWidget;
/// The function called when the emoji is selected
final OnEmojiSelected onEmojiSelected;
/// The function called when backspace button is pressed
final OnBackspacePressed? onBackspacePressed;
/// Config for customizations
final Config config;
@override
EmojiPickerState createState() => EmojiPickerState();
}
class EmojiPickerState extends State<EmojiPicker> {
static const platform = MethodChannel('emoji_picker_flutter');
List<CategoryEmoji> categoryEmoji = List.empty(growable: true);
List<RecentEmoji> recentEmoji = List.empty(growable: true);
late Future<void> updateEmojiFuture;
// Prevent emojis to be reloaded with every build
bool loaded = false;
@override
void initState() {
super.initState();
updateEmojiFuture = _updateEmojis();
}
@override
void didUpdateWidget(covariant EmojiPicker oldWidget) {
if (oldWidget.config != widget.config) {
// Config changed - rebuild EmojiPickerView completely
loaded = false;
updateEmojiFuture = _updateEmojis();
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
if (!loaded) {
// Load emojis
updateEmojiFuture.then(
(value) => WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
loaded = true;
});
}),
);
// Show loading indicator
return const Center(child: CircularProgressIndicator());
}
if (widget.config.showRecentsTab) {
categoryEmoji[0].emoji =
recentEmoji.map((e) => e.emoji).toList().cast<Emoji>();
}
var state = EmojiViewState(
categoryEmoji,
_getOnEmojiListener(),
widget.onBackspacePressed,
);
// Build
return widget.customWidget == null
? DefaultEmojiPickerView(widget.config, state)
: widget.customWidget!(widget.config, state);
}
// Add recent emoji handling to tap listener
OnEmojiSelected _getOnEmojiListener() {
return (category, emoji) {
if (widget.config.showRecentsTab) {
_addEmojiToRecentlyUsed(emoji).then((value) {
if (category != Category.RECENT && mounted) {
setState(() {
// rebuild to update recent emoji tab
// when it is not current tab
});
}
});
}
widget.onEmojiSelected(category, emoji);
};
}
// Initialize emoji data
Future<void> _updateEmojis() async {
categoryEmoji.clear();
if (widget.config.showRecentsTab) {
recentEmoji = await _getRecentEmojis();
final List<Emoji> recentEmojiMap =
recentEmoji.map((e) => e.emoji).toList().cast<Emoji>();
categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap));
}
categoryEmoji.addAll([
CategoryEmoji(Category.SMILEYS,
await _getAvailableEmojis(emoji_list.smileys, title: 'smileys'),),
CategoryEmoji(Category.ANIMALS,
await _getAvailableEmojis(emoji_list.animals, title: 'animals'),),
CategoryEmoji(Category.FOODS,
await _getAvailableEmojis(emoji_list.foods, title: 'foods'),),
CategoryEmoji(
Category.ACTIVITIES,
await _getAvailableEmojis(emoji_list.activities,
title: 'activities',),),
CategoryEmoji(Category.TRAVEL,
await _getAvailableEmojis(emoji_list.travel, title: 'travel'),),
CategoryEmoji(Category.OBJECTS,
await _getAvailableEmojis(emoji_list.objects, title: 'objects'),),
CategoryEmoji(Category.SYMBOLS,
await _getAvailableEmojis(emoji_list.symbols, title: 'symbols'),),
CategoryEmoji(Category.FLAGS,
await _getAvailableEmojis(emoji_list.flags, title: 'flags'),)
]);
}
// Get available emoji for given category title
Future<List<Emoji>> _getAvailableEmojis(Map<String, String> map,
{required String title,}) async {
Map<String, String>? newMap;
// Get Emojis cached locally if available
newMap = await _restoreFilteredEmojis(title);
if (newMap == null) {
// Check if emoji is available on this platform
newMap = await _getPlatformAvailableEmoji(map);
// Save available Emojis to local storage for faster loading next time
if (newMap != null) {
await _cacheFilteredEmojis(title, newMap);
}
}
// Map to Emoji Object
return newMap!.entries
.map<Emoji>((entry) => Emoji(entry.key, entry.value))
.toList();
}
// Check if emoji is available on current platform
Future<Map<String, String>?> _getPlatformAvailableEmoji(
Map<String, String> emoji,) async {
if (Platform.isAndroid) {
Map<String, String>? filtered = {};
var delimiter = '|';
try {
var entries = emoji.values.join(delimiter);
var keys = emoji.keys.join(delimiter);
var result = (await platform.invokeMethod<String>('checkAvailability',
{'emojiKeys': keys, 'emojiEntries': entries},)) as String;
var resultKeys = result.split(delimiter);
for (var i = 0; i < resultKeys.length; i++) {
filtered[resultKeys[i]] = emoji[resultKeys[i]]!;
}
} on PlatformException catch (_) {
filtered = null;
}
return filtered;
} else {
return emoji;
}
}
// Restore locally cached emoji
Future<Map<String, String>?> _restoreFilteredEmojis(String title) async {
final prefs = await SharedPreferences.getInstance();
var emojiJson = prefs.getString(title);
if (emojiJson == null) {
return null;
}
var emojis =
Map<String, String>.from(jsonDecode(emojiJson) as Map<String, dynamic>);
return emojis;
}
// Stores filtered emoji locally for faster access next time
Future<void> _cacheFilteredEmojis(
String title, Map<String, String> emojis,) async {
final prefs = await SharedPreferences.getInstance();
var emojiJson = jsonEncode(emojis);
prefs.setString(title, emojiJson);
}
// Returns list of recently used emoji from cache
Future<List<RecentEmoji>> _getRecentEmojis() async {
final prefs = await SharedPreferences.getInstance();
var emojiJson = prefs.getString('recent');
if (emojiJson == null) {
return [];
}
var json = jsonDecode(emojiJson) as List<dynamic>;
return json.map<RecentEmoji>(RecentEmoji.fromJson).toList();
}
// Add an emoji to recently used list or increase its counter
Future<void> _addEmojiToRecentlyUsed(Emoji emoji) async {
final prefs = await SharedPreferences.getInstance();
var recentEmojiIndex =
recentEmoji.indexWhere((element) => element.emoji.emoji == emoji.emoji);
if (recentEmojiIndex != -1) {
// Already exist in recent list
// Just update counter
recentEmoji[recentEmojiIndex].counter++;
} else {
recentEmoji.add(RecentEmoji(emoji, 1));
}
// Sort by counter desc
recentEmoji.sort((a, b) => b.counter - a.counter);
// Limit entries to recentsLimit
recentEmoji = recentEmoji.sublist(
0, min(widget.config.recentsLimit, recentEmoji.length),);
// save locally
prefs.setString('recent', jsonEncode(recentEmoji));
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'config.dart';
import 'emoji_view_state.dart';
/// Template class for custom implementation
/// Inherit this class to create your own EmojiPicker
abstract class EmojiPickerBuilder extends StatefulWidget {
/// Constructor
const EmojiPickerBuilder(this.config, this.state, {Key? key})
: super(key: key);
/// Config for customizations
final Config config;
/// State that holds current emoji data
final EmojiViewState state;
}

View File

@ -0,0 +1,21 @@
import 'models/category_models.dart';
import 'emoji_picker.dart';
/// State that holds current emoji data
class EmojiViewState {
/// Constructor
EmojiViewState(
this.categoryEmoji,
this.onEmojiSelected,
this.onBackspacePressed,
);
/// List of all category including their emoji
final List<CategoryEmoji> categoryEmoji;
/// Callback when pressed on emoji
final OnEmojiSelected onEmojiSelected;
/// Callback when pressed on backspace
final OnBackspacePressed? onBackspacePressed;
}

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'emoji_model.dart';
import '../emoji_picker.dart';
/// Container for Category and their emoji
class CategoryEmoji {
/// Constructor
CategoryEmoji(this.category, this.emoji);
/// Category instance
final Category category;
/// List of emoji of this category
List<Emoji> emoji;
@override
String toString() {
return 'Name: $category, Emoji: $emoji';
}
}
/// Class that defines the icon representing a [Category]
class CategoryIcon {
/// Icon of Category
const CategoryIcon({
required this.icon,
this.color = const Color(0xffd3d3d3),
this.selectedColor = const Color(0xffb2b2b2),
});
/// The icon to represent the category
final IconData icon;
/// The default color of the icon
final Color color;
/// The color of the icon once the category is selected
final Color selectedColor;
}
/// Class used to define all the [CategoryIcon] shown for each [Category]
///
/// This allows the keyboard to be personalized by changing icons shown.
/// If a [CategoryIcon] is set as null or not defined during initialization,
/// the default icons will be used instead
class CategoryIcons {
/// Constructor
const CategoryIcons({
this.recentIcon = Icons.access_time,
this.smileyIcon = Icons.tag_faces,
this.animalIcon = Icons.pets,
this.foodIcon = Icons.fastfood,
this.activityIcon = Icons.directions_run,
this.travelIcon = Icons.location_city,
this.objectIcon = Icons.lightbulb_outline,
this.symbolIcon = Icons.emoji_symbols,
this.flagIcon = Icons.flag,
this.searchIcon = Icons.search,
});
/// Icon for [Category.RECENT]
final IconData recentIcon;
/// Icon for [Category.SMILEYS]
final IconData smileyIcon;
/// Icon for [Category.ANIMALS]
final IconData animalIcon;
/// Icon for [Category.FOODS]
final IconData foodIcon;
/// Icon for [Category.ACTIVITIES]
final IconData activityIcon;
/// Icon for [Category.TRAVEL]
final IconData travelIcon;
/// Icon for [Category.OBJECTS]
final IconData objectIcon;
/// Icon for [Category.SYMBOLS]
final IconData symbolIcon;
/// Icon for [Category.FLAGS]
final IconData flagIcon;
/// Icon for [Category.SEARCH]
final IconData searchIcon;
}

View File

@ -0,0 +1,32 @@
/// A class to store data for each individual emoji
class Emoji {
/// Emoji constructor
const Emoji(this.name, this.emoji);
/// The name or description for this emoji
final String name;
/// The unicode string for this emoji
///
/// This is the string that should be displayed to view the emoji
final String emoji;
@override
String toString() {
// return 'Name: $name, Emoji: $emoji';
return name;
}
/// Parse Emoji from json
static Emoji fromJson(Map<String, dynamic> json) {
return Emoji(json['name'] as String, json['emoji'] as String);
}
/// Encode Emoji to json
Map<String, dynamic> toJson() {
return {
'name': name,
'emoji': emoji,
};
}
}

View File

@ -0,0 +1,30 @@
import 'emoji_model.dart';
/// Class that holds an recent emoji
/// Recent Emoji has an instance of the emoji
/// And a counter, which counts how often this emoji
/// has been used before
class RecentEmoji {
/// Constructor
RecentEmoji(this.emoji, this.counter);
/// Emoji instance
final Emoji emoji;
/// Counter how often emoji has been used before
int counter = 0;
/// Parse RecentEmoji from json
static RecentEmoji fromJson(dynamic json) {
return RecentEmoji(
Emoji.fromJson(json['emoji'] as Map<String, dynamic>),
json['counter'] as int,
);
}
/// Encode RecentEmoji to json
Map<String, dynamic> toJson() => {
'emoji': emoji,
'counter': counter,
};
}

View File

@ -0,0 +1,33 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
extension FlowyTintExtension on FlowyTint {
String tintName(
AppFlowyEditorLocalizations l10n, {
ThemeMode? themeMode,
String? theme,
}) {
switch (this) {
case FlowyTint.tint1:
return l10n.lightLightTint1;
case FlowyTint.tint2:
return l10n.lightLightTint2;
case FlowyTint.tint3:
return l10n.lightLightTint3;
case FlowyTint.tint4:
return l10n.lightLightTint4;
case FlowyTint.tint5:
return l10n.lightLightTint5;
case FlowyTint.tint6:
return l10n.lightLightTint6;
case FlowyTint.tint7:
return l10n.lightLightTint7;
case FlowyTint.tint8:
return l10n.lightLightTint8;
case FlowyTint.tint9:
return l10n.lightLightTint9;
}
}
}

View File

@ -1,168 +0,0 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEvent insertHorizontalRule = ShortcutEvent(
key: 'Horizontal rule',
command: 'Minus',
handler: _insertHorzaontalRule,
);
ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
if (textNode.toPlainText() == '--') {
final transaction = editorState.transaction
..deleteText(textNode, 0, 2)
..insertNode(
textNode.path,
Node(
type: 'horizontal_rule',
children: LinkedList(),
attributes: {},
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.apply(transaction);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
name: 'Horizontal rule',
icon: (editorState, onSelected) => Icon(
Icons.horizontal_rule,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['horizontal rule'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final textNode = textNodes.first;
if (textNode.toPlainText().isEmpty) {
final transaction = editorState.transaction
..insertNode(
textNode.path,
Node(
type: 'horizontal_rule',
children: LinkedList(),
attributes: {},
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.apply(transaction);
} else {
final transaction = editorState.transaction
..insertNode(
selection.end.path.next,
TextNode(
children: LinkedList(),
attributes: {
'subtype': 'horizontal_rule',
},
delta: Delta()..insert('---'),
),
)
..afterSelection = selection;
editorState.apply(transaction);
}
},
);
class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _HorizontalRuleWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return true;
};
}
class _HorizontalRuleWidget extends StatefulWidget {
const _HorizontalRuleWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState();
}
class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget>
with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Container(
height: 1,
color: Colors.grey,
),
);
}
@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
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.borderLine;
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@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

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class Svg extends StatelessWidget {
const Svg({
Key? key,
this.name,
this.width,
this.height,
this.color,
this.number,
this.padding,
}) : super(key: key);
final String? name;
final double? width;
final double? height;
final Color? color;
final int? number;
final EdgeInsets? padding;
final _defaultWidth = 20.0;
final _defaultHeight = 20.0;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.all(0),
child: _buildSvg(),
);
}
Widget _buildSvg() {
if (name != null) {
return SvgPicture.asset(
'assets/images/$name.svg',
colorFilter:
color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null,
fit: BoxFit.fill,
height: height,
width: width,
package: 'appflowy_editor_plugins',
);
} else if (number != null) {
final numberText =
'<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><text x="30" y="150" fill="black" font-size="160">$number.</text></svg>';
return SvgPicture.string(
numberText,
width: width ?? _defaultWidth,
height: height ?? _defaultHeight,
);
}
return Container();
}
}

View File

@ -0,0 +1,224 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_math_fork/flutter_math.dart';
const String kMathEquationType = 'math_equation';
const String kMathEquationAttr = 'math_equation';
SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
name: 'Math Equation',
icon: (editorState, onSelected) => Icon(
Icons.text_fields_rounded,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['tex, latex, katex', 'math equation'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final textNode = textNodes.first;
final Path mathEquationNodePath;
if (textNode.toPlainText().isEmpty) {
mathEquationNodePath = selection.end.path;
} else {
mathEquationNodePath = selection.end.path.next;
}
// insert the math equation node
final transaction = editorState.transaction
..insertNode(
mathEquationNodePath,
Node(type: kMathEquationType, attributes: {kMathEquationAttr: ''}),
)
..afterSelection = selection;
editorState.apply(transaction);
// tricy to show the editing dialog.
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final mathEquationState = editorState.document
.nodeAtPath(mathEquationNodePath)
?.key
.currentState;
if (mathEquationState != null &&
mathEquationState is _MathEquationNodeWidgetState) {
mathEquationState.showEditingDialog();
}
});
},
);
class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _MathEquationNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator =>
(node) => node.attributes[kMathEquationAttr] is String;
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.svg(
name: "delete",
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _MathEquationNodeWidget extends StatefulWidget {
const _MathEquationNodeWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_MathEquationNodeWidget> createState() =>
_MathEquationNodeWidgetState();
}
class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
String get _mathEquation =>
widget.node.attributes[kMathEquationAttr] as String;
bool _isHover = false;
@override
Widget build(BuildContext context) {
return InkWell(
onHover: (value) {
setState(() {
_isHover = value;
});
},
onTap: () {
showEditingDialog();
},
child: Stack(
children: [
_buildMathEquation(context),
],
),
);
}
Widget _buildMathEquation(BuildContext context) {
return Container(
width: MediaQuery.of(context).size.width,
constraints: const BoxConstraints(minHeight: 50),
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: _isHover || _mathEquation.isEmpty
? Theme.of(context).colorScheme.tertiaryContainer
: Colors.transparent,
),
child: Center(
child: _mathEquation.isEmpty
? FlowyText.medium(
LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(),
fontSize: 16,
)
: Math.tex(
_mathEquation,
textStyle: const TextStyle(fontSize: 20),
mathStyle: MathStyle.display,
),
),
);
}
void showEditingDialog() {
showDialog(
context: context,
builder: (context) {
final controller = TextEditingController(text: _mathEquation);
return AlertDialog(
backgroundColor: Theme.of(context).canvasColor,
title: Text(
LocaleKeys.document_plugins_mathEquation_editMathEquation.tr(),
),
content: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (key) {
if (key is! RawKeyDownEvent) return;
if (key.logicalKey == LogicalKeyboardKey.enter &&
!key.isShiftPressed) {
_updateMathEquation(controller.text, context);
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
_dismiss(context);
}
},
child: TextField(
autofocus: true,
controller: controller,
maxLines: null,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'E = MC^2',
),
),
),
actions: [
SecondaryTextButton(
LocaleKeys.button_Cancel.tr(),
onPressed: () => _dismiss(context),
),
PrimaryTextButton(
LocaleKeys.button_Done.tr(),
onPressed: () => _updateMathEquation(controller.text, context),
),
],
actionsPadding: const EdgeInsets.only(bottom: 20),
actionsAlignment: MainAxisAlignment.spaceAround,
);
},
);
}
void _updateMathEquation(String mathEquation, BuildContext context) {
if (mathEquation == _mathEquation) {
_dismiss(context);
return;
}
final transaction = widget.editorState.transaction;
transaction.updateNode(
widget.node,
{
kMathEquationAttr: mathEquation,
},
);
widget.editorState.apply(transaction);
_dismiss(context);
}
void _dismiss(BuildContext context) {
Navigator.of(context).pop();
}
}

View File

@ -0,0 +1,21 @@
export 'board/board_node_widget.dart';
export 'board/board_menu_item.dart';
export 'board/board_view_menu_item.dart';
export 'callout/callout_node_widget.dart';
export 'code_block/code_block_node_widget.dart';
export 'code_block/code_block_shortcut_event.dart';
export 'cover/change_cover_popover_bloc.dart';
export 'cover/cover_node_widget.dart';
export 'cover/cover_image_picker.dart';
export 'divider/divider_node_widget.dart';
export 'divider/divider_shortcut_event.dart';
export 'emoji_picker/emoji_menu_item.dart';
export 'extensions/flowy_tint_extension.dart';
export 'grid/grid_menu_item.dart';
export 'grid/grid_node_widget.dart';
export 'grid/grid_view_menu_item.dart';
export 'math_equation/math_equation_node_widget.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/auto_completion_plugins.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';