diff --git a/frontend/app_flowy/lib/plugins/document/document_page.dart b/frontend/app_flowy/lib/plugins/document/document_page.dart index 57dbcd1367..0120f06e8a 100644 --- a/frontend/app_flowy/lib/plugins/document/document_page.dart +++ b/frontend/app_flowy/lib/plugins/document/document_page.dart @@ -1,8 +1,8 @@ import 'package:app_flowy/plugins/document/editor_styles.dart'; -import 'package:app_flowy/plugins/document/presentation/plugins/horizontal_rule_node_widget.dart'; import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/plugins/document/presentation/banner.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; @@ -101,10 +101,28 @@ class _DocumentPageState extends State { editorState: editorState, autoFocus: editorState.document.isEmpty, customBuilders: { - 'horizontal_rule': HorizontalRuleWidgetBuilder(), + // Divider + kDividerType: DividerWidgetBuilder(), + // Math Equation + kMathEquationType: MathEquationNodeWidgetBuidler(), + // Code Block + kCodeBlockType: CodeBlockNodeWidgetBuilder(), }, shortcutEvents: [ - insertHorizontalRule, + // Divider + insertDividerEvent, + // Code Block + enterInCodeBlock, + ignoreKeysInCodeBlock, + pasteInCodeBlock, + ], + selectionMenuItems: [ + // Divider + dividerMenuItem, + // Math Equation + mathEquationMenuItem, + // Code Block + codeBlockMenuItem, ], themeData: theme.copyWith(extensions: [ ...theme.extensions.values, diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart index 5fa772d11f..1eca47a1b4 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; class SimpleEditor extends StatelessWidget { @@ -30,10 +31,35 @@ class SimpleEditor extends StatelessWidget { ), ); onEditorStateChange(editorState); + return AppFlowyEditor( editorState: editorState, themeData: themeData, autoFocus: editorState.document.isEmpty, + customBuilders: { + // Divider + kDividerType: DividerWidgetBuilder(), + // Math Equation + kMathEquationType: MathEquationNodeWidgetBuidler(), + // Code Block + kCodeBlockType: CodeBlockNodeWidgetBuilder(), + }, + shortcutEvents: [ + // Divider + insertDividerEvent, + // Code Block + enterInCodeBlock, + ignoreKeysInCodeBlock, + pasteInCodeBlock, + ], + selectionMenuItems: [ + // Divider + dividerMenuItem, + // Math Equation + mathEquationMenuItem, + // Code Block + codeBlockMenuItem, + ], ); } else { return const Center( diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart deleted file mode 100644 index 0ca302de18..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart +++ /dev/null @@ -1,166 +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(); - 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: (_, __) => const Icon( - Icons.horizontal_rule, - color: Colors.black, - size: 18.0, - ), - keywords: ['horizontal rule'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - 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 { - @override - Widget build(NodeWidgetContext context) { - return _HorizontalRuleWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator 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 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); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart deleted file mode 100644 index c9b0e8d478..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_math_fork/flutter_math.dart'; - -SelectionMenuItem teXBlockMenuItem = SelectionMenuItem( - name: () => 'Tex', - icon: (_, __) => const Icon( - Icons.text_fields_rounded, - color: Colors.black, - size: 18.0, - ), - keywords: ['tex, latex, katex'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (selection == null || !selection.isCollapsed || textNodes.isEmpty) { - return; - } - final Path texNodePath; - if (textNodes.first.toPlainText().isEmpty) { - texNodePath = selection.end.path; - final transaction = editorState.transaction - ..insertNode( - selection.end.path, - Node( - type: 'tex', - children: LinkedList(), - attributes: {'tex': ''}, - ), - ) - ..deleteNode(textNodes.first) - ..afterSelection = selection; - editorState.apply(transaction); - } else { - texNodePath = selection.end.path.next; - final transaction = editorState.transaction - ..insertNode( - selection.end.path.next, - Node( - type: 'tex', - children: LinkedList(), - attributes: {'tex': ''}, - ), - ) - ..afterSelection = selection; - editorState.apply(transaction); - } - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - final texState = - editorState.document.nodeAtPath(texNodePath)?.key?.currentState; - if (texState != null && texState is __TeXBlockNodeWidgetState) { - texState.showEditingDialog(); - } - }); - }, -); - -class TeXBlockNodeWidgetBuidler extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _TeXBlockNodeWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return node.attributes['tex'] is String; - }; -} - -class _TeXBlockNodeWidget extends StatefulWidget { - const _TeXBlockNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - final Node node; - final EditorState editorState; - - @override - State<_TeXBlockNodeWidget> createState() => __TeXBlockNodeWidgetState(); -} - -class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> { - String get _tex => widget.node.attributes['tex'] as String; - bool _isHover = false; - - @override - Widget build(BuildContext context) { - return InkWell( - onHover: (value) { - setState(() { - _isHover = value; - }); - }, - onTap: () { - showEditingDialog(); - }, - child: Stack( - children: [ - _buildTex(context), - if (_isHover) _buildDeleteButton(context), - ], - ), - ); - } - - Widget _buildTex(BuildContext context) { - return Container( - width: MediaQuery.of(context).size.width, - padding: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: _isHover ? Colors.grey[200] : Colors.transparent, - ), - child: Center( - child: Math.tex( - _tex, - textStyle: const TextStyle(fontSize: 20), - mathStyle: MathStyle.display, - ), - ), - ); - } - - Widget _buildDeleteButton(BuildContext context) { - return Positioned( - top: -5, - right: -5, - child: IconButton( - icon: Icon( - Icons.delete_outline, - color: Colors.blue[400], - size: 16, - ), - onPressed: () { - final transaction = widget.editorState.transaction - ..deleteNode(widget.node); - widget.editorState.apply(transaction); - }, - ), - ); - } - - void showEditingDialog() { - showDialog( - context: context, - builder: (context) { - final controller = TextEditingController(text: _tex); - return AlertDialog( - title: const Text('Edit Katex'), - content: TextField( - controller: controller, - maxLines: null, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - if (controller.text != _tex) { - final transaction = widget.editorState.transaction - ..updateNode( - widget.node, - {'tex': controller.text}, - ); - widget.editorState.apply(transaction); - } - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml index a60ba61f30..885573b00a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: universal_html: ^2.0.8 highlight: ^0.7.0 flutter_math_fork: ^0.6.3+1 + appflowy_editor_plugins: + path: ../../../packages/appflowy_editor_plugins dev_dependencies: flutter_test: diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/.gitignore b/frontend/app_flowy/packages/appflowy_editor_plugins/.gitignore new file mode 100644 index 0000000000..96486fd930 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/.metadata b/frontend/app_flowy/packages/appflowy_editor_plugins/.metadata new file mode 100644 index 0000000000..d0b84561d4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + channel: unknown + +project_type: package diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_editor_plugins/CHANGELOG.md new file mode 100644 index 0000000000..41cc7d8192 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/LICENSE b/frontend/app_flowy/packages/appflowy_editor_plugins/LICENSE new file mode 100644 index 0000000000..ba75c69f7f --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/README.md b/frontend/app_flowy/packages/appflowy_editor_plugins/README.md new file mode 100644 index 0000000000..8b55e735b5 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/analysis_options.yaml b/frontend/app_flowy/packages/appflowy_editor_plugins/analysis_options.yaml new file mode 100644 index 0000000000..a5744c1cfb --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart new file mode 100644 index 0000000000..771a88720c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart @@ -0,0 +1,12 @@ +library appflowy_editor_plugins; + +// Divider +export 'src/divider/divider_node_widget.dart'; +export 'src/divider/divider_shortcut_event.dart'; + +// Math Equation +export 'src/math_ equation/math_equation_node_widget.dart'; + +// Code Block +export 'src/code_block/code_block_node_widget.dart'; +export 'src/code_block/code_block_shortcut_event.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart similarity index 60% rename from frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart rename to frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart index 5ecf4d4ed8..75a0fd48ee 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart @@ -1,93 +1,12 @@ -import 'dart:collection'; - import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:highlight/highlight.dart' as highlight; import 'package:highlight/languages/all.dart'; -ShortcutEvent enterInCodeBlock = ShortcutEvent( - key: 'Enter in code block', - command: 'enter', - handler: _enterInCodeBlockHandler, -); - -ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent( - key: 'White space in code block', - command: 'space,slash,shift+underscore', - handler: _ignorekHandler, -); - -ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final codeBlockNode = - nodes.whereType().where((node) => node.id == 'text/code_block'); - if (codeBlockNode.length != 1 || selection == null) { - return KeyEventResult.ignored; - } - if (selection.isCollapsed) { - final transaction = editorState.transaction - ..insertText(codeBlockNode.first, selection.end.offset, '\n'); - editorState.apply(transaction); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -ShortcutEventHandler _ignorekHandler = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final codeBlockNodes = - nodes.whereType().where((node) => node.id == 'text/code_block'); - if (codeBlockNodes.length == 1) { - return KeyEventResult.skipRemainingHandlers; - } - return KeyEventResult.ignored; -}; - -SelectionMenuItem codeBlockMenuItem = SelectionMenuItem( - name: () => 'Code Block', - icon: (_, __) => const Icon( - Icons.abc, - color: Colors.black, - size: 18.0, - ), - keywords: ['code block'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (selection == null || textNodes.isEmpty) { - return; - } - if (textNodes.first.toPlainText().isEmpty) { - final transaction = editorState.transaction - ..updateNode(textNodes.first, { - 'subtype': 'code_block', - 'theme': 'vs', - 'language': null, - }) - ..afterSelection = selection; - editorState.apply(transaction); - } else { - final transaction = editorState.transaction - ..insertNode( - selection.end.path.next, - TextNode( - children: LinkedList(), - attributes: { - 'subtype': 'code_block', - 'theme': 'vs', - 'language': null, - }, - delta: Delta()..insert('\n'), - ), - ) - ..afterSelection = selection; - editorState.apply(transaction); - } - }, -); +const String kCodeBlockType = 'text/$kCodeBlockSubType'; +const String kCodeBlockSubType = 'code_block'; +const String kCodeBlockAttrTheme = 'theme'; +const String kCodeBlockAttrLanguage = 'language'; class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder { @override @@ -101,7 +20,8 @@ class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder { @override NodeValidator get nodeValidator => (node) { - return node is TextNode && node.attributes['theme'] is String; + return node is TextNode && + node.attributes[kCodeBlockAttrTheme] is String; }; } @@ -121,9 +41,11 @@ class _CodeBlockNodeWidge extends StatefulWidget { class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge> with SelectableMixin, DefaultSelectable { - final _richTextKey = GlobalKey(debugLabel: 'code_block_text'); - final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20); - String? get _language => widget.textNode.attributes['language'] as String?; + final _richTextKey = GlobalKey(debugLabel: kCodeBlockType); + final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30); + bool _isHover = false; + String? get _language => + widget.textNode.attributes[kCodeBlockAttrLanguage] as String?; String? _detectLanguage; @override @@ -138,11 +60,20 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge> @override Widget build(BuildContext context) { - return Stack( - children: [ - _buildCodeBlock(context), - _buildSwitchCodeButton(context), - ], + return InkWell( + onHover: (value) { + setState(() { + _isHover = value; + }); + }, + onTap: () {}, + child: Stack( + children: [ + _buildCodeBlock(context), + _buildSwitchCodeButton(context), + if (_isHover) _buildDeleteButton(context), + ], + ), ); } @@ -177,25 +108,49 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge> Widget _buildSwitchCodeButton(BuildContext context) { return Positioned( top: -5, - right: 0, - child: DropdownButton( - value: _detectLanguage, - onChanged: (value) { + left: 10, + child: SizedBox( + height: 35, + child: DropdownButton( + 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>((String value) { + return DropdownMenuItem( + value: value, + child: Text( + value, + style: const TextStyle(fontSize: 12.0), + ), + ); + }).toList(growable: false), + ), + ), + ); + } + + Widget _buildDeleteButton(BuildContext context) { + return Positioned( + top: -5, + right: -5, + child: IconButton( + icon: Icon( + Icons.delete_forever_outlined, + color: widget.editorState.editorStyle.selectionMenuItemIconColor, + size: 16, + ), + onPressed: () { final transaction = widget.editorState.transaction - ..updateNode(widget.textNode, { - 'language': value, - }); + ..deleteNode(widget.textNode); widget.editorState.apply(transaction); }, - items: allLanguages.keys.map>((String value) { - return DropdownMenuItem( - value: value, - child: Text( - value, - style: const TextStyle(fontSize: 12.0), - ), - ); - }).toList(growable: false), ), ); } diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_shortcut_event.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_shortcut_event.dart new file mode 100644 index 0000000000..62ae1e44c5 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_shortcut_event.dart @@ -0,0 +1,124 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/src/code_block/code_block_node_widget.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().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().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().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(); + if (selection == null || textNodes.isEmpty) { + return; + } + final transaction = editorState.transaction; + if (textNodes.first.toPlainText().isEmpty) { + 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); + }, +); diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_node_widget.dart new file mode 100644 index 0000000000..73d7030804 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_node_widget.dart @@ -0,0 +1,84 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +const String kDividerType = 'divider'; + +class DividerWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return _DividerWidget( + key: context.node.key, + node: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator 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 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); +} diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_shortcut_event.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_shortcut_event.dart new file mode 100644 index 0000000000..2d4ad39bf1 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_shortcut_event.dart @@ -0,0 +1,72 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/src/divider/divider_node_widget.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(); + 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(); + 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); + } + }, +); diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart new file mode 100644 index 0000000000..76121a1ca9 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart @@ -0,0 +1,220 @@ +import 'package:appflowy_editor/appflowy_editor.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'; + +// TODO: l10n +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(); + 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 { + @override + Widget build(NodeWidgetContext context) { + return _MathEquationNodeWidget( + key: context.node.key, + node: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => + (node) => node.attributes[kMathEquationAttr] is String; +} + +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), + if (_isHover) _buildDeleteButton(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 + ? Colors.grey[200] + : Colors.transparent, + ), + child: Center( + child: _mathEquation.isEmpty + ? Text( + 'Add a Math Equation', + style: widget.editorState.editorStyle.placeholderTextStyle, + ) + : Math.tex( + _mathEquation, + textStyle: const TextStyle(fontSize: 20), + mathStyle: MathStyle.display, + ), + ), + ); + } + + Widget _buildDeleteButton(BuildContext context) { + return Positioned( + top: -5, + right: -5, + child: IconButton( + icon: Icon( + Icons.delete_forever_outlined, + color: widget.editorState.editorStyle.selectionMenuItemIconColor, + size: 16, + ), + onPressed: () { + final transaction = widget.editorState.transaction + ..deleteNode(widget.node); + widget.editorState.apply(transaction); + }, + ), + ); + } + + void showEditingDialog() { + showDialog( + context: context, + builder: (context) { + final controller = TextEditingController(text: _mathEquation); + return AlertDialog( + title: const Text('Edit Math Equation'), + 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: [ + TextButton( + onPressed: () => _dismiss(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => _updateMathEquation(controller.text, context), + child: const Text('Done'), + ), + ], + ); + }, + ); + } + + 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(); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml new file mode 100644 index 0000000000..9d62309c22 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml @@ -0,0 +1,60 @@ +name: appflowy_editor_plugins +description: A new Flutter package project. +version: 0.0.1 +homepage: https://github.com/AppFlowy-IO/AppFlowy + +publish_to: none + +environment: + sdk: ">=2.17.6 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + appflowy_editor: + path: ../appflowy_editor + flutter_math_fork: ^0.6.3+1 + highlight: ^0.7.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_editor_plugins/test/appflowy_editor_plugins_test.dart b/frontend/app_flowy/packages/appflowy_editor_plugins/test/appflowy_editor_plugins_test.dart new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor_plugins/test/appflowy_editor_plugins_test.dart @@ -0,0 +1 @@ + diff --git a/frontend/app_flowy/pubspec.lock b/frontend/app_flowy/pubspec.lock index 9ccd2d3076..13218b2051 100644 --- a/frontend/app_flowy/pubspec.lock +++ b/frontend/app_flowy/pubspec.lock @@ -36,6 +36,13 @@ packages: relative: true source: path version: "0.0.7" + appflowy_editor_plugins: + dependency: "direct main" + description: + path: "packages/appflowy_editor_plugins" + relative: true + source: path + version: "0.0.1" appflowy_popover: dependency: "direct main" description: @@ -471,6 +478,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_math_fork: + dependency: transitive + description: + name: flutter_math_fork + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3+1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -556,6 +570,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + highlight: + dependency: transitive + description: + name: highlight + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" hotkey_manager: dependency: "direct main" description: @@ -1402,5 +1423,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=2.17.0 <3.0.0" + dart: ">=2.17.6 <3.0.0" flutter: ">=3.0.0" diff --git a/frontend/app_flowy/pubspec.yaml b/frontend/app_flowy/pubspec.yaml index 2cdf5ca3f0..608e7cf823 100644 --- a/frontend/app_flowy/pubspec.yaml +++ b/frontend/app_flowy/pubspec.yaml @@ -91,6 +91,8 @@ dependencies: google_fonts: ^3.0.1 file_picker: <=5.0.0 percent_indicator: ^4.0.1 + appflowy_editor_plugins: + path: packages/appflowy_editor_plugins dev_dependencies: flutter_lints: ^2.0.1