diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/check.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/check.svg new file mode 100644 index 0000000000..8446cced9f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/point.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/point.svg new file mode 100644 index 0000000000..be88518d0d --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/point.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg new file mode 100644 index 0000000000..0f3d33f6d3 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg new file mode 100644 index 0000000000..6c487795c6 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json index 00ef06da5d..b90aec8369 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -37,7 +37,22 @@ "type": "text", "delta": [{ "insert": "Click anywhere and just start typing." }], "attributes": { - "checkbox": true + "list": "todo", + "todo": false + } + }, + { + "type": "text", + "delta": [{ "insert": "Click anywhere and just start typing." }], + "attributes": { + "list": "bullet" + } + }, + { + "type": "text", + "delta": [{ "insert": "Click anywhere and just start typing." }], + "attributes": { + "list": "bullet" } }, { @@ -77,7 +92,9 @@ "insert": "1. Click the '?' at the bottom right for help and support." } ], - "attributes": {} + "attributes": { + "quotes": true + } }, { "type": "text", diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json new file mode 100644 index 0000000000..0dad009cd2 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json @@ -0,0 +1,193 @@ +{ + "document": { + "type": "editor", + "attributes": {}, + "children": [ + { + "type": "image", + "attributes": { + "image_src": "https://images.pexels.com/photos/2253275/pexels-photo-2253275.jpeg?cs=srgb&dl=pexels-helena-lopes-2253275.jpg&fm=jpg" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "🌶 Read Me" + } + ], + "attributes": { + "heading": "h1" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "👋 Welcome to Appflowy" + } + ], + "attributes": { + "heading": "h2" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Here are the basics:" + } + ], + "attributes": { + "heading": "h3" + } + }, + { + "type": "text", + "delta": [ + { "insert": "Click " }, + { "insert": "anywhere", "attributes": { "underline": true } }, + { "insert": " and just typing." } + ], + "attributes": { + "list": "todo", + "todo": true + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hit" + }, + { + "insert": " / ", + "attributes": { "highlightColor": "0xFFFFFF00" } + }, + { + "insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc." + } + ], + "attributes": { + "list": "todo", + "todo": true + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Highlight any text, and use the menu that pops up to " + }, + { "insert": "style", "attributes": { "bold": true } }, + { "insert": " your ", "attributes": { "italic": true } }, + { "insert": "writing", "attributes": { "strikethrough": true } }, + { "insert": "." } + ], + "attributes": { + "list": "todo", + "todo": true + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Here are the examples:" + } + ], + "attributes": { + "heading": "h3" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "list": "bullet" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "list": "bullet" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "list": "bullet" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "quote": true + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "quote": true + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "number": 1 + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "number": 2 + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "number": 3 + } + } + ] + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart new file mode 100644 index 0000000000..01da3ab593 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart @@ -0,0 +1,234 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +// copy from https://docs.flutter.dev/cookbook/effects/expandable-fab +@immutable +class ExpandableFab extends StatefulWidget { + const ExpandableFab({ + super.key, + this.initialOpen, + required this.distance, + required this.children, + }); + + final bool? initialOpen; + final double distance; + final List children; + + @override + State createState() => _ExpandableFabState(); +} + +class _ExpandableFabState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _expandAnimation; + bool _open = false; + + @override + void initState() { + super.initState(); + _open = widget.initialOpen ?? false; + _controller = AnimationController( + value: _open ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + vsync: this, + ); + _expandAnimation = CurvedAnimation( + curve: Curves.fastOutSlowIn, + reverseCurve: Curves.easeOutQuad, + parent: _controller, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() { + _open = !_open; + if (_open) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Stack( + alignment: Alignment.bottomRight, + clipBehavior: Clip.none, + children: [ + _buildTapToCloseFab(), + ..._buildExpandingActionButtons(), + _buildTapToOpenFab(), + ], + ), + ); + } + + Widget _buildTapToCloseFab() { + return SizedBox( + width: 56.0, + height: 56.0, + child: Center( + child: Material( + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + elevation: 4.0, + child: InkWell( + onTap: _toggle, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + Icons.close, + color: Theme.of(context).primaryColor, + ), + ), + ), + ), + ), + ); + } + + List _buildExpandingActionButtons() { + final children = []; + final count = widget.children.length; + final step = 90.0 / (count - 1); + for (var i = 0, angleInDegrees = 0.0; + i < count; + i++, angleInDegrees += step) { + children.add( + _ExpandingActionButton( + directionInDegrees: angleInDegrees, + maxDistance: widget.distance, + progress: _expandAnimation, + child: widget.children[i], + ), + ); + } + return children; + } + + Widget _buildTapToOpenFab() { + return IgnorePointer( + ignoring: _open, + child: AnimatedContainer( + transformAlignment: Alignment.center, + transform: Matrix4.diagonal3Values( + _open ? 0.7 : 1.0, + _open ? 0.7 : 1.0, + 1.0, + ), + duration: const Duration(milliseconds: 250), + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + child: AnimatedOpacity( + opacity: _open ? 0.0 : 1.0, + curve: const Interval(0.25, 1.0, curve: Curves.easeInOut), + duration: const Duration(milliseconds: 250), + child: FloatingActionButton( + onPressed: _toggle, + child: const Icon(Icons.create), + ), + ), + ), + ); + } +} + +@immutable +class _ExpandingActionButton extends StatelessWidget { + const _ExpandingActionButton({ + required this.directionInDegrees, + required this.maxDistance, + required this.progress, + required this.child, + }); + + final double directionInDegrees; + final double maxDistance; + final Animation progress; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: progress, + builder: (context, child) { + final offset = Offset.fromDirection( + directionInDegrees * (math.pi / 180.0), + progress.value * maxDistance, + ); + return Positioned( + right: 4.0 + offset.dx, + bottom: 4.0 + offset.dy, + child: Transform.rotate( + angle: (1.0 - progress.value) * math.pi / 2, + child: child!, + ), + ); + }, + child: FadeTransition( + opacity: progress, + child: child, + ), + ); + } +} + +@immutable +class ActionButton extends StatelessWidget { + const ActionButton({ + super.key, + this.onPressed, + required this.icon, + }); + + final VoidCallback? onPressed; + final Widget icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + color: theme.colorScheme.secondary, + elevation: 4.0, + child: IconButton( + onPressed: onPressed, + icon: icon, + color: theme.colorScheme.onSecondary, + ), + ); + } +} + +@immutable +class FakeItem extends StatelessWidget { + const FakeItem({ + super.key, + required this.isBig, + }); + + final bool isBig; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0), + height: isBig ? 128.0 : 36.0, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: Colors.grey.shade300, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index 112c1dcd4f..6105703fa0 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -1,10 +1,11 @@ import 'dart:convert'; +import 'package:example/expandable_floating_action_button.dart'; import 'package:example/plugin/document_node_widget.dart'; import 'package:example/plugin/selected_text_node_widget.dart'; import 'package:example/plugin/text_with_heading_node_widget.dart'; import 'package:example/plugin/image_node_widget.dart'; -import 'package:example/plugin/text_node_widget.dart'; +import 'package:example/plugin/old_text_node_widget.dart'; import 'package:example/plugin/text_with_check_box_node_widget.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/flowy_editor.dart'; @@ -60,13 +61,13 @@ class MyHomePage extends StatefulWidget { class _MyHomePageState extends State { final RenderPlugins renderPlugins = RenderPlugins(); late EditorState _editorState; + int page = 0; @override void initState() { super.initState(); renderPlugins ..register('editor', EditorNodeWidgetBuilder.create) - ..register('text', SelectedTextNodeBuilder.create) ..register('image', ImageNodeBuilder.create) ..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create) ..register('text/with-heading', TextWithHeadingNodeBuilder.create); @@ -80,53 +81,95 @@ class _MyHomePageState extends State { // the App.build method, and use it to set our appbar title. title: Text(widget.title), ), - body: FutureBuilder( - future: rootBundle.loadString('assets/document.json'), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } else { - final data = Map.from(json.decode(snapshot.data!)); - final document = StateTree.fromJson(data); - _editorState = EditorState( - document: document, - renderPlugins: renderPlugins, - ); - return FlowyEditor( - editorState: _editorState, - keyEventHandlers: const [], - shortcuts: [ - // TODO: this won't work, just a example for now. - { - 'h1': (editorState, eventName) { - debugPrint('shortcut => $eventName'); - final selectedNodes = editorState.selectedNodes; - if (selectedNodes.isEmpty) { - return; - } - final textNode = selectedNodes.first as TextNode; - TransactionBuilder(editorState) - ..formatText(textNode, 0, textNode.toRawString().length, { - 'heading': 'h1', - }) - ..commit(); - } - }, - { - 'bold': (editorState, eventName) => - debugPrint('shortcut => $eventName') - }, - { - 'underline': (editorState, eventName) => - debugPrint('shortcut => $eventName') - }, - ], - ); - } - }, + body: _buildBody(), + floatingActionButton: ExpandableFab( + distance: 112.0, + children: [ + ActionButton( + onPressed: () { + if (page == 0) return; + setState(() { + page = 0; + }); + }, + icon: const Icon(Icons.note_add), + ), + ActionButton( + onPressed: () { + if (page == 1) return; + setState(() { + page = 1; + }); + }, + icon: const Icon(Icons.text_fields), + ), + ], ), ); } + + Widget _buildBody() { + if (page == 0) { + return _buildFlowyEditor(); + } else if (page == 1) { + return _buildTextField(); + } + return Container(); + } + + Widget _buildFlowyEditor() { + return FutureBuilder( + future: rootBundle.loadString('assets/example.json'), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } else { + final data = Map.from(json.decode(snapshot.data!)); + final document = StateTree.fromJson(data); + _editorState = EditorState( + document: document, + renderPlugins: renderPlugins, + ); + return FlowyEditor( + editorState: _editorState, + keyEventHandlers: const [], + shortcuts: [ + // TODO: this won't work, just a example for now. + { + 'h1': (editorState, eventName) { + debugPrint('shortcut => $eventName'); + final selectedNodes = editorState.selectedNodes; + if (selectedNodes.isEmpty) { + return; + } + final textNode = selectedNodes.first as TextNode; + TransactionBuilder(editorState) + ..formatText(textNode, 0, textNode.toRawString().length, { + 'heading': 'h1', + }) + ..commit(); + } + }, + { + 'bold': (editorState, eventName) => + debugPrint('shortcut => $eventName') + }, + { + 'underline': (editorState, eventName) => + debugPrint('shortcut => $eventName') + }, + ], + ); + } + }, + ); + } + + Widget _buildTextField() { + return const Center( + child: TextField(), + ); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart index 2db1ef89c4..2a70da2ba2 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart @@ -9,7 +9,7 @@ class EditorNodeWidgetBuilder extends NodeWidgetBuilder { }) : super.create(); @override - Widget build(BuildContext buildContext) { + Widget build(BuildContext context) { return SingleChildScrollView( key: key, child: _EditorNodeWidget( diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index c5084df2fb..e33ff83e2f 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -11,7 +11,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder { }) : super.create(); @override - Widget build(BuildContext buildContext) { + Widget build(BuildContext context) { return _ImageNodeWidget( key: key, node: node, @@ -83,7 +83,10 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { Widget _build(BuildContext context) { return Column( children: [ - Image.network(src), + Image.network( + src, + height: 150.0, + ), if (node.children.isNotEmpty) Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart new file mode 100644 index 0000000000..bad07fe6a6 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart @@ -0,0 +1,352 @@ +// import 'package:flowy_editor/document/position.dart'; +// import 'package:flowy_editor/document/selection.dart'; +// import 'package:flutter/gestures.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flowy_editor/flowy_editor.dart'; +// import 'package:flutter/services.dart'; +// import 'package:url_launcher/url_launcher_string.dart'; +// import 'flowy_selectable_text.dart'; + +// class TextNodeBuilder extends NodeWidgetBuilder { +// TextNodeBuilder.create({ +// required super.node, +// required super.editorState, +// required super.key, +// }) : super.create() { +// nodeValidator = ((node) { +// return node.type == 'text'; +// }); +// } + +// @override +// Widget build(BuildContext context) { +// return _TextNodeWidget(key: key, node: node, editorState: editorState); +// } +// } + +// class _TextNodeWidget extends StatefulWidget { +// final Node node; +// final EditorState editorState; + +// const _TextNodeWidget({ +// Key? key, +// required this.node, +// required this.editorState, +// }) : super(key: key); + +// @override +// State<_TextNodeWidget> createState() => __TextNodeWidgetState(); +// } + +// class __TextNodeWidgetState extends State<_TextNodeWidget> +// implements DeltaTextInputClient { +// TextNode get node => widget.node as TextNode; +// EditorState get editorState => widget.editorState; +// bool _metaKeyDown = false; +// bool _shiftKeyDown = false; + +// TextInputConnection? _textInputConnection; + +// @override +// Widget build(BuildContext context) { +// return Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// FlowySelectableText.rich( +// node.toTextSpan(), +// showCursor: true, +// enableInteractiveSelection: true, +// onSelectionChanged: _onSelectionChanged, +// // autofocus: true, +// focusNode: FocusNode( +// onKey: _onKey, +// ), +// ), +// if (node.children.isNotEmpty) +// ...node.children.map( +// (e) => editorState.renderPlugins.buildWidget( +// context: NodeWidgetContext( +// buildContext: context, +// node: e, +// editorState: editorState, +// ), +// ), +// ), +// const SizedBox( +// height: 10, +// ), +// ], +// ); +// } + +// KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) { +// debugPrint('key: $event'); +// if (event is RawKeyDownEvent) { +// final sel = _globalSelectionToLocal(node, editorState.cursorSelection); +// if (event.logicalKey == LogicalKeyboardKey.backspace) { +// _backDeleteTextAtSelection(sel); +// return KeyEventResult.handled; +// } else if (event.logicalKey == LogicalKeyboardKey.delete) { +// _forwardDeleteTextAtSelection(sel); +// return KeyEventResult.handled; +// } else if (event.logicalKey == LogicalKeyboardKey.metaLeft || +// event.logicalKey == LogicalKeyboardKey.metaRight) { +// _metaKeyDown = true; +// } else if (event.logicalKey == LogicalKeyboardKey.shiftLeft || +// event.logicalKey == LogicalKeyboardKey.shiftRight) { +// _shiftKeyDown = true; +// } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) { +// if (_shiftKeyDown) { +// editorState.undoManager.redo(); +// } else { +// editorState.undoManager.undo(); +// } +// } +// } else if (event is RawKeyUpEvent) { +// if (event.logicalKey == LogicalKeyboardKey.metaLeft || +// event.logicalKey == LogicalKeyboardKey.metaRight) { +// _metaKeyDown = false; +// } +// if (event.logicalKey == LogicalKeyboardKey.shiftLeft || +// event.logicalKey == LogicalKeyboardKey.shiftRight) { +// _shiftKeyDown = false; +// } +// } +// return KeyEventResult.ignored; +// } + +// void _onSelectionChanged( +// TextSelection selection, SelectionChangedCause? cause) { +// _textInputConnection?.close(); +// _textInputConnection = TextInput.attach( +// this, +// const TextInputConfiguration( +// enableDeltaModel: true, +// inputType: TextInputType.multiline, +// textCapitalization: TextCapitalization.sentences, +// ), +// ); +// editorState.cursorSelection = _localSelectionToGlobal(node, selection); +// _textInputConnection +// ?..show() +// ..setEditingState( +// TextEditingValue( +// text: node.toRawString(), +// selection: selection, +// ), +// ); +// } + +// _backDeleteTextAtSelection(TextSelection? sel) { +// if (sel == null) { +// return; +// } +// if (sel.start == 0) { +// return; +// } + +// if (sel.isCollapsed) { +// TransactionBuilder(editorState) +// ..deleteText(node, sel.start - 1, 1) +// ..commit(); +// } else { +// TransactionBuilder(editorState) +// ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) +// ..commit(); +// } + +// _setEditingStateFromGlobal(); +// } + +// _forwardDeleteTextAtSelection(TextSelection? sel) { +// if (sel == null) { +// return; +// } + +// if (sel.isCollapsed) { +// TransactionBuilder(editorState) +// ..deleteText(node, sel.start, 1) +// ..commit(); +// } else { +// TransactionBuilder(editorState) +// ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) +// ..commit(); +// } +// _setEditingStateFromGlobal(); +// } + +// _setEditingStateFromGlobal() { +// _textInputConnection?.setEditingState(TextEditingValue( +// text: node.toRawString(), +// selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? +// const TextSelection.collapsed(offset: 0))); +// } + +// @override +// void connectionClosed() { +// // TODO: implement connectionClosed +// } + +// @override +// // TODO: implement currentAutofillScope +// AutofillScope? get currentAutofillScope => throw UnimplementedError(); + +// @override +// // TODO: implement currentTextEditingValue +// TextEditingValue? get currentTextEditingValue => TextEditingValue( +// text: node.toRawString(), +// selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? +// const TextSelection.collapsed(offset: 0)); + +// @override +// void insertTextPlaceholder(Size size) { +// // TODO: implement insertTextPlaceholder +// } + +// @override +// void performAction(TextInputAction action) {} + +// @override +// void performPrivateCommand(String action, Map data) { +// // TODO: implement performPrivateCommand +// } + +// @override +// void removeTextPlaceholder() { +// // TODO: implement removeTextPlaceholder +// } + +// @override +// void showAutocorrectionPromptRect(int start, int end) { +// // TODO: implement showAutocorrectionPromptRect +// } + +// @override +// void showToolbar() { +// // TODO: implement showToolbar +// } + +// @override +// void updateEditingValue(TextEditingValue value) {} + +// @override +// void updateEditingValueWithDeltas(List textEditingDeltas) { +// for (final textDelta in textEditingDeltas) { +// if (textDelta is TextEditingDeltaInsertion) { +// TransactionBuilder(editorState) +// ..insertText(node, textDelta.insertionOffset, textDelta.textInserted) +// ..commit(); +// } else if (textDelta is TextEditingDeltaDeletion) { +// TransactionBuilder(editorState) +// ..deleteText(node, textDelta.deletedRange.start, +// textDelta.deletedRange.end - textDelta.deletedRange.start) +// ..commit(); +// } +// } +// } + +// @override +// void updateFloatingCursor(RawFloatingCursorPoint point) { +// // TODO: implement updateFloatingCursor +// } +// } + +// extension on TextNode { +// TextSpan toTextSpan() => TextSpan( +// children: delta.operations +// .whereType() +// .map((op) => op.toTextSpan()) +// .toList()); +// } + +// extension on TextInsert { +// TextSpan toTextSpan() { +// FontWeight? fontWeight; +// FontStyle? fontStyle; +// TextDecoration? decoration; +// GestureRecognizer? gestureRecognizer; +// Color? color; +// Color highLightColor = Colors.transparent; +// double fontSize = 16.0; +// final attributes = this.attributes; +// if (attributes?['bold'] == true) { +// fontWeight = FontWeight.bold; +// } +// if (attributes?['italic'] == true) { +// fontStyle = FontStyle.italic; +// } +// if (attributes?['underline'] == true) { +// decoration = TextDecoration.underline; +// } +// if (attributes?['strikethrough'] == true) { +// decoration = TextDecoration.lineThrough; +// } +// if (attributes?['highlight'] is String) { +// highLightColor = Color(int.parse(attributes!['highlight'])); +// } +// if (attributes?['href'] is String) { +// color = const Color.fromARGB(255, 55, 120, 245); +// decoration = TextDecoration.underline; +// gestureRecognizer = TapGestureRecognizer() +// ..onTap = () { +// launchUrlString(attributes?['href']); +// }; +// } +// final heading = attributes?['heading'] as String?; +// if (heading != null) { +// // TODO: make it better +// if (heading == 'h1') { +// fontSize = 30.0; +// } else if (heading == 'h2') { +// fontSize = 20.0; +// } +// fontWeight = FontWeight.bold; +// } +// return TextSpan( +// text: content, +// style: TextStyle( +// fontWeight: fontWeight, +// fontStyle: fontStyle, +// decoration: decoration, +// color: color, +// fontSize: fontSize, +// backgroundColor: highLightColor, +// ), +// recognizer: gestureRecognizer, +// ); +// } +// } + +// TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) { +// if (globalSel == null) { +// return null; +// } +// final nodePath = node.path; + +// if (!pathEquals(nodePath, globalSel.start.path)) { +// return null; +// } +// if (globalSel.isCollapsed) { +// return TextSelection( +// baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset); +// } else { +// if (pathEquals(globalSel.start.path, globalSel.end.path)) { +// return TextSelection( +// baseOffset: globalSel.start.offset, +// extentOffset: globalSel.end.offset); +// } +// } +// return null; +// } + +// Selection? _localSelectionToGlobal(Node node, TextSelection? sel) { +// if (sel == null) { +// return null; +// } +// final nodePath = node.path; + +// return Selection( +// start: Position(path: nodePath, offset: sel.baseOffset), +// end: Position(path: nodePath, offset: sel.extentOffset), +// ); +// } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart index 894f6b1848..e16abaa1aa 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart @@ -22,7 +22,7 @@ class SelectedTextNodeBuilder extends NodeWidgetBuilder { } @override - Widget build(BuildContext buildContext) { + Widget build(BuildContext context) { return _SelectedTextNodeWidget( key: key, node: node, @@ -96,14 +96,15 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> } @override - TextSelection? getCurrentTextSelection() { - return _textSelection; - } - - @override - Offset getOffsetByTextSelection(TextSelection textSelection) { - final offset = _computeCursorRect(textSelection.baseOffset).center; - return _renderParagraph.localToGlobal(offset); + TextSelection? getTextSelectionInSelection(Selection selection) { + assert(selection.isCollapsed); + if (!selection.isCollapsed) { + return null; + } + return TextSelection( + baseOffset: selection.start.offset, + extentOffset: selection.end.offset, + ); } @override diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart deleted file mode 100644 index a67ebcd2ad..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ /dev/null @@ -1,352 +0,0 @@ -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'flowy_selectable_text.dart'; - -class TextNodeBuilder extends NodeWidgetBuilder { - TextNodeBuilder.create({ - required super.node, - required super.editorState, - required super.key, - }) : super.create() { - nodeValidator = ((node) { - return node.type == 'text'; - }); - } - - @override - Widget build(BuildContext buildContext) { - return _TextNodeWidget(key: key, node: node, editorState: editorState); - } -} - -class _TextNodeWidget extends StatefulWidget { - final Node node; - final EditorState editorState; - - const _TextNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - @override - State<_TextNodeWidget> createState() => __TextNodeWidgetState(); -} - -class __TextNodeWidgetState extends State<_TextNodeWidget> - implements DeltaTextInputClient { - TextNode get node => widget.node as TextNode; - EditorState get editorState => widget.editorState; - bool _metaKeyDown = false; - bool _shiftKeyDown = false; - - TextInputConnection? _textInputConnection; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySelectableText.rich( - node.toTextSpan(), - showCursor: true, - enableInteractiveSelection: true, - onSelectionChanged: _onSelectionChanged, - // autofocus: true, - focusNode: FocusNode( - onKey: _onKey, - ), - ), - if (node.children.isNotEmpty) - ...node.children.map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, - ), - ), - ), - const SizedBox( - height: 10, - ), - ], - ); - } - - KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) { - debugPrint('key: $event'); - if (event is RawKeyDownEvent) { - final sel = _globalSelectionToLocal(node, editorState.cursorSelection); - if (event.logicalKey == LogicalKeyboardKey.backspace) { - _backDeleteTextAtSelection(sel); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.delete) { - _forwardDeleteTextAtSelection(sel); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.metaLeft || - event.logicalKey == LogicalKeyboardKey.metaRight) { - _metaKeyDown = true; - } else if (event.logicalKey == LogicalKeyboardKey.shiftLeft || - event.logicalKey == LogicalKeyboardKey.shiftRight) { - _shiftKeyDown = true; - } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) { - if (_shiftKeyDown) { - editorState.undoManager.redo(); - } else { - editorState.undoManager.undo(); - } - } - } else if (event is RawKeyUpEvent) { - if (event.logicalKey == LogicalKeyboardKey.metaLeft || - event.logicalKey == LogicalKeyboardKey.metaRight) { - _metaKeyDown = false; - } - if (event.logicalKey == LogicalKeyboardKey.shiftLeft || - event.logicalKey == LogicalKeyboardKey.shiftRight) { - _shiftKeyDown = false; - } - } - return KeyEventResult.ignored; - } - - void _onSelectionChanged( - TextSelection selection, SelectionChangedCause? cause) { - _textInputConnection?.close(); - _textInputConnection = TextInput.attach( - this, - const TextInputConfiguration( - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - ), - ); - editorState.cursorSelection = _localSelectionToGlobal(node, selection); - _textInputConnection - ?..show() - ..setEditingState( - TextEditingValue( - text: node.toRawString(), - selection: selection, - ), - ); - } - - _backDeleteTextAtSelection(TextSelection? sel) { - if (sel == null) { - return; - } - if (sel.start == 0) { - return; - } - - if (sel.isCollapsed) { - TransactionBuilder(editorState) - ..deleteText(node, sel.start - 1, 1) - ..commit(); - } else { - TransactionBuilder(editorState) - ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) - ..commit(); - } - - _setEditingStateFromGlobal(); - } - - _forwardDeleteTextAtSelection(TextSelection? sel) { - if (sel == null) { - return; - } - - if (sel.isCollapsed) { - TransactionBuilder(editorState) - ..deleteText(node, sel.start, 1) - ..commit(); - } else { - TransactionBuilder(editorState) - ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) - ..commit(); - } - _setEditingStateFromGlobal(); - } - - _setEditingStateFromGlobal() { - _textInputConnection?.setEditingState(TextEditingValue( - text: node.toRawString(), - selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? - const TextSelection.collapsed(offset: 0))); - } - - @override - void connectionClosed() { - // TODO: implement connectionClosed - } - - @override - // TODO: implement currentAutofillScope - AutofillScope? get currentAutofillScope => throw UnimplementedError(); - - @override - // TODO: implement currentTextEditingValue - TextEditingValue? get currentTextEditingValue => TextEditingValue( - text: node.toRawString(), - selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? - const TextSelection.collapsed(offset: 0)); - - @override - void insertTextPlaceholder(Size size) { - // TODO: implement insertTextPlaceholder - } - - @override - void performAction(TextInputAction action) {} - - @override - void performPrivateCommand(String action, Map data) { - // TODO: implement performPrivateCommand - } - - @override - void removeTextPlaceholder() { - // TODO: implement removeTextPlaceholder - } - - @override - void showAutocorrectionPromptRect(int start, int end) { - // TODO: implement showAutocorrectionPromptRect - } - - @override - void showToolbar() { - // TODO: implement showToolbar - } - - @override - void updateEditingValue(TextEditingValue value) {} - - @override - void updateEditingValueWithDeltas(List textEditingDeltas) { - for (final textDelta in textEditingDeltas) { - if (textDelta is TextEditingDeltaInsertion) { - TransactionBuilder(editorState) - ..insertText(node, textDelta.insertionOffset, textDelta.textInserted) - ..commit(); - } else if (textDelta is TextEditingDeltaDeletion) { - TransactionBuilder(editorState) - ..deleteText(node, textDelta.deletedRange.start, - textDelta.deletedRange.end - textDelta.deletedRange.start) - ..commit(); - } - } - } - - @override - void updateFloatingCursor(RawFloatingCursorPoint point) { - // TODO: implement updateFloatingCursor - } -} - -extension on TextNode { - TextSpan toTextSpan() => TextSpan( - children: delta.operations - .whereType() - .map((op) => op.toTextSpan()) - .toList()); -} - -extension on TextInsert { - TextSpan toTextSpan() { - FontWeight? fontWeight; - FontStyle? fontStyle; - TextDecoration? decoration; - GestureRecognizer? gestureRecognizer; - Color? color; - Color highLightColor = Colors.transparent; - double fontSize = 16.0; - final attributes = this.attributes; - if (attributes?['bold'] == true) { - fontWeight = FontWeight.bold; - } - if (attributes?['italic'] == true) { - fontStyle = FontStyle.italic; - } - if (attributes?['underline'] == true) { - decoration = TextDecoration.underline; - } - if (attributes?['strikethrough'] == true) { - decoration = TextDecoration.lineThrough; - } - if (attributes?['highlight'] is String) { - highLightColor = Color(int.parse(attributes!['highlight'])); - } - if (attributes?['href'] is String) { - color = const Color.fromARGB(255, 55, 120, 245); - decoration = TextDecoration.underline; - gestureRecognizer = TapGestureRecognizer() - ..onTap = () { - launchUrlString(attributes?['href']); - }; - } - final heading = attributes?['heading'] as String?; - if (heading != null) { - // TODO: make it better - if (heading == 'h1') { - fontSize = 30.0; - } else if (heading == 'h2') { - fontSize = 20.0; - } - fontWeight = FontWeight.bold; - } - return TextSpan( - text: content, - style: TextStyle( - fontWeight: fontWeight, - fontStyle: fontStyle, - decoration: decoration, - color: color, - fontSize: fontSize, - backgroundColor: highLightColor, - ), - recognizer: gestureRecognizer, - ); - } -} - -TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) { - if (globalSel == null) { - return null; - } - final nodePath = node.path; - - if (!pathEquals(nodePath, globalSel.start.path)) { - return null; - } - if (globalSel.isCollapsed) { - return TextSelection( - baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset); - } else { - if (pathEquals(globalSel.start.path, globalSel.end.path)) { - return TextSelection( - baseOffset: globalSel.start.offset, - extentOffset: globalSel.end.offset); - } - } - return null; -} - -Selection? _localSelectionToGlobal(Node node, TextSelection? sel) { - if (sel == null) { - return null; - } - final nodePath = node.path; - - return Selection( - start: Position(path: nodePath, offset: sel.baseOffset), - end: Position(path: nodePath, offset: sel.extentOffset), - ); -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart index ff6c6e9932..f7985ed564 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart @@ -12,7 +12,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { bool get isCompleted => node.attributes['checkbox'] as bool; @override - Widget build(BuildContext buildContext) { + Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -20,7 +20,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { Expanded( child: renderPlugins.buildWidget( context: NodeWidgetContext( - buildContext: buildContext, + buildContext: context, node: node, editorState: editorState, ), diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart index 22022a65ec..c4bd027888 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart @@ -27,13 +27,13 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder { } @override - Widget build(BuildContext buildContext) { + Widget build(BuildContext context) { return Column( children: [ buildPadding(), renderPlugins.buildWidget( context: NodeWidgetContext( - buildContext: buildContext, + buildContext: context, node: node, editorState: editorState, ), diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock index 6dd22ff45f..cfadcb8242 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock @@ -76,6 +76,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1+1" flutter_test: dependency: "direct dev" description: flutter @@ -135,6 +142,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" plugin_platform_interface: dependency: transitive description: @@ -259,6 +287,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" sdks: dart: ">=2.17.0 <3.0.0" - flutter: ">=2.10.0" + flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml index 11df9b36ee..9a80a73a0a 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml @@ -64,6 +64,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - document.json + - example.json # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 9871bf24ee..bdd6da444d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'package:flowy_editor/document/path.dart'; import 'package:flowy_editor/document/text_delta.dart'; +import 'package:flowy_editor/operation/operation.dart'; import 'package:flutter/material.dart'; import './attributes.dart'; @@ -176,6 +177,14 @@ class TextNode extends Node { required Delta delta, }) : _delta = delta; + TextNode.empty() + : _delta = Delta([TextInsert('')]), + super( + type: 'text', + children: LinkedList(), + attributes: {}, + ); + Delta get delta { return _delta; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index cd503843c2..e388ea3661 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; import 'package:flowy_editor/service/service.dart'; import 'package:flutter/material.dart'; @@ -25,6 +26,7 @@ class ApplyOptions { class EditorState { final StateTree document; final RenderPlugins renderPlugins; + List selectedNodes = []; // Service reference. @@ -39,6 +41,8 @@ class EditorState { required this.document, required this.renderPlugins, }) { + // FIXME: abstract render plugins as a service. + renderPlugins.register('text', RichTextNodeWidgetBuilder.create); undoManager.state = this; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart index 49cc38f749..52b7596240 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/selection.dart'; import 'package:flowy_editor/extensions/object_extensions.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart index b37d846482..793dc552dd 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart @@ -22,4 +22,15 @@ extension PathExtensions on Path { } return true; } + + Path get next { + Path nextPath = Path.from(this, growable: true); + if (isEmpty) { + return nextPath; + } + final last = nextPath.last; + return nextPath + ..removeLast() + ..add(last + 1); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index 3f8510d8b3..91c6b1c4b0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -12,3 +12,5 @@ export 'package:flowy_editor/operation/transaction_builder.dart'; export 'package:flowy_editor/operation/operation.dart'; export 'package:flowy_editor/editor_state.dart'; export 'package:flowy_editor/service/editor_service.dart'; +export 'package:flowy_editor/document/selection.dart'; +export 'package:flowy_editor/document/position.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart new file mode 100644 index 0000000000..136b5db4bc --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class FlowySvg extends StatelessWidget { + const FlowySvg({ + Key? key, + this.name, + this.size = const Size(20, 20), + this.color, + this.number, + }) : super(key: key); + + final String? name; + final Size size; + final Color? color; + final int? number; + + @override + Widget build(BuildContext context) { + if (name != null) { + return SizedBox.fromSize( + size: size, + child: SvgPicture.asset( + 'assets/images/$name.svg', + color: color, + package: 'flowy_editor', + ), + ); + } else if (number != null) { + final numberText = + '$number.'; + return SizedBox.fromSize( + size: size, + child: SvgPicture.string(numberText), + ); + } + return Container(); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart index a3d35f9dad..214818f60a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart @@ -26,14 +26,14 @@ class NodeWidgetBuilder { /// Render the current [Node] /// and the layout style of [Node.Children]. Widget build( - BuildContext buildContext, + BuildContext context, ) => throw UnimplementedError(); /// TODO: refactore this part. - /// return widget embeded with ChangeNotifier and widget itself. + /// return widget embedded with ChangeNotifier and widget itself. Widget call( - BuildContext buildContext, + BuildContext context, ) { /// TODO: Validate the node /// if failed, stop call build function, @@ -43,20 +43,20 @@ class NodeWidgetBuilder { 'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }'); } - return _buildNodeChangeNotifier(buildContext); + return _build(context); } - Widget _buildNodeChangeNotifier(BuildContext buildContext) { - return ChangeNotifierProvider.value( - value: node, - builder: (_, __) => Consumer( - builder: ((context, value, child) { - debugPrint('Node changed, and rebuilding...'); - return CompositedTransformTarget( - link: node.layerLink, - child: build(context), - ); - }), + Widget _build(BuildContext context) { + return CompositedTransformTarget( + link: node.layerLink, + child: ChangeNotifierProvider.value( + value: node, + builder: (context, child) => Consumer( + builder: ((context, value, child) { + debugPrint('Node is rebuilding...'); + return build(context); + }), + ), ), ); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart new file mode 100644 index 0000000000..66c87a2dd4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -0,0 +1,282 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/document/text_delta.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/document/path.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/render/node_widget_builder.dart'; +import 'package:flowy_editor/render/render_plugins.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/extensions/object_extensions.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { + RichTextNodeWidgetBuilder.create({ + required super.editorState, + required super.node, + required super.key, + }) : super.create(); + + @override + Widget build(BuildContext context) { + return FlowyRichText( + key: key, + textNode: node as TextNode, + editorState: editorState, + ); + } +} + +class FlowyRichText extends StatefulWidget { + const FlowyRichText({ + Key? key, + this.cursorHeight, + this.cursorWidth = 2.0, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final double? cursorHeight; + final double cursorWidth; + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _FlowyRichTextState(); +} + +class _FlowyRichTextState extends State with Selectable { + final _textKey = GlobalKey(); + final _decorationKey = GlobalKey(); + + EditorState get _editorState => widget.editorState; + TextNode get _textNode => widget.textNode; + RenderParagraph get _renderParagraph => + _textKey.currentContext?.findRenderObject() as RenderParagraph; + + @override + Widget build(BuildContext context) { + final attributes = _textNode.attributes; + // TODO: use factory method ?? + if (attributes.list == 'todo') { + return _buildTodoListRichText(context); + } else if (attributes.list == 'bullet') { + return _buildBulletedListRichText(context); + } else if (attributes.quote == true) { + return _buildQuotedRichText(context); + } else if (attributes.heading != null) { + return _buildHeadingRichText(context); + } else if (attributes.number != null) { + return _buildNumberListRichText(context); + } + return _buildRichText(context); + } + + @override + Position start() => Position(path: _textNode.path, offset: 0); + + @override + Position end() => + Position(path: _textNode.path, offset: _textNode.toRawString().length); + + @override + Rect getCursorRectInPosition(Position position) { + final textPosition = TextPosition(offset: position.offset); + final baseRect = frontWidgetRect(); + final cursorOffset = + _renderParagraph.getOffsetForCaret(textPosition, Rect.zero); + final cursorHeight = widget.cursorHeight ?? + _renderParagraph.getFullHeightForCaret(textPosition) ?? + 5.0; // default height + return Rect.fromLTWH( + baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2), + cursorOffset.dy, + widget.cursorWidth, + cursorHeight, + ); + } + + @override + Position getPositionInOffset(Offset start) { + final offset = _renderParagraph.globalToLocal(start); + final baseOffset = _renderParagraph.getPositionForOffset(offset).offset; + return Position(path: _textNode.path, offset: baseOffset); + } + + @override + List getRectsInSelection(Selection selection) { + assert(pathEquals(selection.start.path, selection.end.path) && + pathEquals(selection.start.path, _textNode.path)); + + final textSelection = TextSelection( + baseOffset: selection.start.offset, + extentOffset: selection.end.offset, + ); + final baseRect = frontWidgetRect(); + return _renderParagraph.getBoxesForSelection(textSelection).map((box) { + final rect = box.toRect(); + return rect.translate(baseRect.centerRight.dx, 0); + }).toList(); + } + + @override + Selection getSelectionInRange(Offset start, Offset end) { + final localStart = _renderParagraph.globalToLocal(start); + final localEnd = _renderParagraph.globalToLocal(end); + final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset; + final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset; + return Selection.single( + path: _textNode.path, + startOffset: baseOffset, + endOffset: extentOffset, + ); + } + + Widget _buildRichText(BuildContext context) { + if (_textNode.children.isEmpty) { + return _buildSingleRichText(context); + } else { + return _buildRichTextWithChildren(context); + } + } + + Widget _buildRichTextWithChildren(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSingleRichText(context), + ..._textNode.children + .map( + (child) => _editorState.renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: child, + editorState: _editorState, + ), + ), + ) + .toList() + ], + ); + } + + Widget _buildSingleRichText(BuildContext context) { + return SizedBox( + width: + MediaQuery.of(context).size.width - 20, // FIXME: use the const value + child: RichText(key: _textKey, text: _decorateTextSpanWithGlobalStyle), + ); + } + + Widget _buildTodoListRichText(BuildContext context) { + final name = _textNode.attributes.todo ? 'check' : 'uncheck'; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + child: FlowySvg( + key: _decorationKey, + name: name, + ), + onTap: () => TransactionBuilder(_editorState) + ..updateNode(_textNode, { + 'todo': !_textNode.attributes.todo, + }) + ..commit(), + ), + _buildRichText(context), + ], + ); + } + + Widget _buildBulletedListRichText(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FlowySvg( + key: _decorationKey, + name: 'point', + ), + _buildRichText(context), + ], + ); + } + + Widget _buildNumberListRichText(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FlowySvg( + key: _decorationKey, + number: _textNode.attributes.number, + ), + _buildRichText(context), + ], + ); + } + + Widget _buildQuotedRichText(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: _decorationKey, + name: 'quote', + ), + _buildRichText(context), + ], + ); + } + + Widget _buildHeadingRichText(BuildContext context) { + // TODO: customize + return Column( + children: [ + const Padding(padding: EdgeInsets.only(top: 5)), + _buildRichText(context), + const Padding(padding: EdgeInsets.only(top: 5)), + ], + ); + } + + Rect frontWidgetRect() { + // FIXME: find a more elegant way to solve this situation. + final renderBox = _decorationKey.currentContext + ?.findRenderObject() + ?.unwrapOrNull(); + if (renderBox != null) { + return renderBox.localToGlobal(Offset.zero) & renderBox.size; + } + return Rect.zero; + } + + TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan( + children: _textSpan.children + ?.whereType() + .map( + (span) => TextSpan( + text: span.text, + style: span.style?.copyWith( + fontSize: _textNode.attributes.fontSize, + color: _textNode.attributes.quoteColor, + ), + recognizer: span.recognizer, + ), + ) + .toList(), + ); + + TextSpan get _textSpan => TextSpan( + children: _textNode.delta.operations + .whereType() + .map((insert) => RichTextStyle( + attributes: insert.attributes ?? {}, + text: insert.content, + ).toTextSpan()) + .toList(growable: false)); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart new file mode 100644 index 0000000000..b4100f9b87 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart @@ -0,0 +1,231 @@ +import 'package:flowy_editor/document/attributes.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// +/// Supported partial rendering types: +/// bold, italic, +/// underline, strikethrough, +/// color, font, +/// href +/// +/// Supported global rendering types: +/// heading: h1, h2, h3, h4, h5, h6, ... +/// block quote, +/// list: ordered list, bulleted list, +/// code block +/// +class StyleKey { + static String bold = 'bold'; + static String italic = 'italic'; + static String underline = 'underline'; + static String strikethrough = 'strikethrough'; + static String color = 'color'; + static String highlightColor = 'highlightColor'; + static String font = 'font'; + static String href = 'href'; + + static String heading = 'heading'; + static String quote = 'quote'; + static String list = 'list'; + static String number = 'number'; + static String todo = 'todo'; + static String code = 'code'; +} + +double baseFontSize = 16.0; +// TODO: customize. +Map headingToFontSize = { + 'h1': baseFontSize + 15, + 'h2': baseFontSize + 12, + 'h3': baseFontSize + 9, + 'h4': baseFontSize + 6, + 'h5': baseFontSize + 3, + 'h6': baseFontSize, +}; + +extension NodeAttributesExtensions on Attributes { + String? get heading { + if (containsKey(StyleKey.heading) && this[StyleKey.heading] is String) { + return this[StyleKey.heading]; + } + return null; + } + + double get fontSize { + if (heading != null) { + return headingToFontSize[heading]!; + } + return baseFontSize; + } + + bool get quote { + if (containsKey(StyleKey.quote) && this[StyleKey.quote] == true) { + return this[StyleKey.quote]; + } + return false; + } + + Color? get quoteColor { + if (quote) { + return Colors.grey; + } + return null; + } + + String? get list { + if (containsKey(StyleKey.list) && this[StyleKey.list] is String) { + return this[StyleKey.list]; + } + return null; + } + + int? get number { + if (containsKey(StyleKey.number) && this[StyleKey.number] is int) { + return this[StyleKey.number]; + } + return null; + } + + bool get todo { + if (containsKey(StyleKey.todo) && this[StyleKey.todo] is bool) { + return this[StyleKey.todo]; + } + return false; + } + + bool get code { + if (containsKey(StyleKey.code) && this[StyleKey.code] == true) { + return this[StyleKey.code]; + } + return false; + } +} + +extension DeltaAttributesExtensions on Attributes { + bool get bold { + return (containsKey(StyleKey.bold) && this[StyleKey.bold] == true); + } + + bool get italic { + return (containsKey(StyleKey.italic) && this[StyleKey.italic] == true); + } + + bool get underline { + return (containsKey(StyleKey.underline) && + this[StyleKey.underline] == true); + } + + bool get strikethrough { + return (containsKey(StyleKey.strikethrough) && + this[StyleKey.strikethrough] == true); + } + + Color? get color { + if (containsKey(StyleKey.color) && this[StyleKey.color] is String) { + return Color( + int.parse(this[StyleKey.color]), + ); + } + return null; + } + + Color? get hightlightColor { + if (containsKey(StyleKey.highlightColor) && + this[StyleKey.highlightColor] is String) { + return Color( + int.parse(this[StyleKey.highlightColor]), + ); + } + return null; + } + + String? get font { + // TODO: unspport now. + return null; + } + + String? get href { + if (containsKey(StyleKey.href) && this[StyleKey.href] is String) { + return this[StyleKey.href]; + } + return null; + } +} + +class RichTextStyle { + // TODO: customize + RichTextStyle({ + required this.attributes, + required this.text, + }); + + final Attributes attributes; + final String text; + + TextSpan toTextSpan() { + return TextSpan( + text: text, + style: TextStyle( + fontWeight: fontWeight, + fontStyle: fontStyle, + fontSize: fontSize, + color: textColor, + backgroundColor: backgroundColor, + decoration: textDecoration, + ), + recognizer: recognizer, + ); + } + + // bold + FontWeight get fontWeight { + if (attributes.bold) { + return FontWeight.bold; + } + return FontWeight.normal; + } + + // underline or strikethrough + TextDecoration get textDecoration { + if (attributes.underline || attributes.href != null) { + return TextDecoration.underline; + } else if (attributes.strikethrough) { + return TextDecoration.lineThrough; + } + return TextDecoration.none; + } + + // font + FontStyle get fontStyle => + attributes.italic ? FontStyle.italic : FontStyle.normal; + + // text color + Color get textColor { + if (attributes.href != null) { + return Colors.lightBlue; + } + return attributes.color ?? Colors.black; + } + + Color get backgroundColor { + return attributes.hightlightColor ?? Colors.transparent; + } + + // font size + double get fontSize { + return baseFontSize; + } + + // recognizer + GestureRecognizer? get recognizer { + final href = attributes.href; + if (href != null) { + return TapGestureRecognizer() + ..onTap = () async { + // FIXME: launch the url + }; + } + return null; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart index 2ba42221f0..58d22bec85 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart @@ -11,7 +11,7 @@ class CursorWidget extends StatefulWidget { this.blinkingInterval = 0.5, }) : super(key: key); - final double blinkingInterval; + final double blinkingInterval; // milliseconds final Color color; final Rect rect; final LayerLink layerLink; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart index 4d155972df..8dfea75135 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; /// mixin Selectable on State { - /// Returns a [List] of the [Rect] selection sorrounded by start and end + /// Returns a [List] of the [Rect] selection surrounded by start and end /// in current widget. /// /// [start] and [end] are the offsets under the global coordinate system. @@ -32,12 +32,5 @@ mixin Selectable on State { /// /// Only the widget rendered by [TextNode] need to implement the detail, /// and the rest can return null. - TextSelection? getCurrentTextSelection() => null; - - /// For [TextNode] only. - /// - /// Retruns a [Offset]. - /// Only the widget rendered by [TextNode] need to implement the detail, - /// and the rest can return [Offset.zero]. - Offset getOffsetByTextSelection(TextSelection textSelection) => Offset.zero; + TextSelection? getTextSelectionInSelection(Selection selection) => null; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index 8b40981ccb..d5223ec36a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -1,4 +1,6 @@ import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart'; +import 'package:flowy_editor/service/input_service.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; import 'package:flowy_editor/service/shortcut_service.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; @@ -36,22 +38,27 @@ class _FlowyEditorState extends State { return FlowySelection( key: editorState.service.selectionServiceKey, editorState: editorState, - child: FlowyKeyboard( - key: editorState.service.keyboardServiceKey, - handlers: [ - slashShortcutHandler, - flowyDeleteNodesHandler, - deleteSingleTextNodeHandler, - arrowKeysHandler, - ...widget.keyEventHandlers, - ], + child: FlowyInput( + key: editorState.service.inputServiceKey, editorState: editorState, - child: FloatingShortcut( - key: editorState.service.floatingShortcutServiceKey, - size: const Size(200, 150), // TODO: support customize size. + child: FlowyKeyboard( + key: editorState.service.keyboardServiceKey, + handlers: [ + slashShortcutHandler, + flowyDeleteNodesHandler, + deleteSingleTextNodeHandler, + arrowKeysHandler, + enterInEdgeOfTextNodeHandler, + ...widget.keyEventHandlers, + ], editorState: editorState, - floatingShortcuts: widget.shortcuts, - child: editorState.build(context), + child: FloatingShortcut( + key: editorState.service.floatingShortcutServiceKey, + size: const Size(200, 150), // TODO: support customize size. + editorState: editorState, + floatingShortcuts: widget.shortcuts, + child: editorState.build(context), + ), ), ), ); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart new file mode 100644 index 0000000000..bdbcd24467 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart @@ -0,0 +1,193 @@ +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +mixin FlowyInputService { + void attach(TextEditingValue textEditingValue); + void setTextEditingValue(TextEditingValue textEditingValue); + void apply(List deltas); + void close(); +} + +/// process input +class FlowyInput extends StatefulWidget { + const FlowyInput({ + Key? key, + required this.editorState, + required this.child, + }) : super(key: key); + + final EditorState editorState; + final Widget child; + + @override + State createState() => _FlowyInputState(); +} + +class _FlowyInputState extends State + with FlowyInputService + implements DeltaTextInputClient { + TextInputConnection? _textInputConnection; + + EditorState get _editorState => widget.editorState; + + @override + void initState() { + super.initState(); + + _editorState.service.selectionService.currentSelectedNodes + .addListener(_onSelectedNodesChange); + } + + @override + void dispose() { + _editorState.service.selectionService.currentSelectedNodes + .removeListener(_onSelectedNodesChange); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + child: widget.child, + ); + } + + @override + void attach(TextEditingValue textEditingValue) { + if (_textInputConnection != null) { + return; + } + + _textInputConnection = TextInput.attach( + this, + const TextInputConfiguration( + // TODO: customize + enableDeltaModel: true, + inputType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + ), + ); + + _textInputConnection + ?..show() + ..setEditingState(textEditingValue); + } + + @override + void setTextEditingValue(TextEditingValue textEditingValue) { + assert(_textInputConnection != null, + 'Must call `attach` before set textEditingValue'); + if (_textInputConnection != null) { + _textInputConnection?.setEditingState(textEditingValue); + } + } + + @override + void apply(List deltas) { +// TODO: implement the detail + for (final delta in deltas) { + if (delta is TextEditingDeltaInsertion) { + } else if (delta is TextEditingDeltaDeletion) { + } else if (delta is TextEditingDeltaReplacement) { + } else if (delta is TextEditingDeltaNonTextUpdate) { + // We don't need to care the [TextEditingDeltaNonTextUpdate]. + // Do nothing. + } + } + } + + @override + void close() { + _textInputConnection?.close(); + _textInputConnection = null; + } + + @override + void connectionClosed() { + // TODO: implement connectionClosed + } + + @override + // TODO: implement currentAutofillScope + AutofillScope? get currentAutofillScope => throw UnimplementedError(); + + @override + // TODO: implement currentTextEditingValue + TextEditingValue? get currentTextEditingValue => throw UnimplementedError(); + + @override + void insertTextPlaceholder(Size size) { + // TODO: implement insertTextPlaceholder + } + + @override + void performAction(TextInputAction action) { + // TODO: implement performAction + } + + @override + void performPrivateCommand(String action, Map data) { + // TODO: implement performPrivateCommand + } + + @override + void removeTextPlaceholder() { + // TODO: implement removeTextPlaceholder + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + // TODO: implement showAutocorrectionPromptRect + } + + @override + void showToolbar() { + // TODO: implement showToolbar + } + + @override + void updateEditingValue(TextEditingValue value) { + // TODO: implement updateEditingValue + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + debugPrint(textEditingDeltas.map((delta) => delta.toString()).toString()); + + apply(textEditingDeltas); + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + // TODO: implement updateFloatingCursor + } + + void _onSelectedNodesChange() { + final nodes = + _editorState.service.selectionService.currentSelectedNodes.value; + final selection = _editorState.service.selectionService.currentSelection; + // FIXME: upward. + if (nodes.isNotEmpty && selection != null) { + final textNodes = nodes.whereType(); + final text = textNodes.fold( + '', (sum, textNode) => '$sum${textNode.toRawString()}\n'); + attach( + TextEditingValue( + text: text, + selection: TextSelection( + baseOffset: selection.start.offset, + extentOffset: selection.end.offset, + ), + ), + ); + } else { + close(); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart index 47a83f314a..f5da6423ae 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart @@ -12,58 +12,58 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) { return KeyEventResult.ignored; } - final selectionNodes = editorState.selectedNodes; - if (selectionNodes.length == 1 && selectionNodes.first is TextNode) { - final node = selectionNodes.first.unwrapOrNull(); - final selectable = node?.key?.currentState?.unwrapOrNull(); - if (selectable != null) { - final textSelection = selectable.getCurrentTextSelection(); - if (textSelection != null) { - if (textSelection.isCollapsed) { - /// Three cases: - /// Delete the zero character, - /// 1. if there is still text node in front of it, then merge them. - /// 2. if not, just ignore - /// Delete the non-zero character, - /// 3. delete the single character. - if (textSelection.baseOffset == 0) { - if (node?.previous != null && node?.previous is TextNode) { - final previous = node!.previous! as TextNode; - final newTextSelection = TextSelection.collapsed( - offset: previous.toRawString().length); - final selectionService = editorState.service.selectionService; - final previousSelectable = - previous.key?.currentState?.unwrapOrNull(); - final newOfset = previousSelectable - ?.getOffsetByTextSelection(newTextSelection); - if (newOfset != null) { - // selectionService.updateCursor(newOfset); - } - // merge - TransactionBuilder(editorState) - ..deleteNode(node) - ..insertText( - previous, previous.toRawString().length, node.toRawString()) - ..commit(); - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } else { - TransactionBuilder(editorState) - ..deleteText(node!, textSelection.baseOffset - 1, 1) - ..commit(); - final newTextSelection = - TextSelection.collapsed(offset: textSelection.baseOffset - 1); - final selectionService = editorState.service.selectionService; - final newOfset = - selectable.getOffsetByTextSelection(newTextSelection); - // selectionService.updateCursor(newOfset); - return KeyEventResult.handled; - } - } - } - } - } + // final selectionNodes = editorState.selectedNodes; + // if (selectionNodes.length == 1 && selectionNodes.first is TextNode) { + // final node = selectionNodes.first.unwrapOrNull(); + // final selectable = node?.key?.currentState?.unwrapOrNull(); + // if (selectable != null) { + // final textSelection = selectable.getCurrentTextSelection(); + // if (textSelection != null) { + // if (textSelection.isCollapsed) { + // /// Three cases: + // /// Delete the zero character, + // /// 1. if there is still text node in front of it, then merge them. + // /// 2. if not, just ignore + // /// Delete the non-zero character, + // /// 3. delete the single character. + // if (textSelection.baseOffset == 0) { + // if (node?.previous != null && node?.previous is TextNode) { + // final previous = node!.previous! as TextNode; + // final newTextSelection = TextSelection.collapsed( + // offset: previous.toRawString().length); + // final selectionService = editorState.service.selectionService; + // final previousSelectable = + // previous.key?.currentState?.unwrapOrNull(); + // final newOfset = previousSelectable + // ?.getOffsetByTextSelection(newTextSelection); + // if (newOfset != null) { + // // selectionService.updateCursor(newOfset); + // } + // // merge + // TransactionBuilder(editorState) + // ..deleteNode(node) + // ..insertText( + // previous, previous.toRawString().length, node.toRawString()) + // ..commit(); + // return KeyEventResult.handled; + // } else { + // return KeyEventResult.ignored; + // } + // } else { + // TransactionBuilder(editorState) + // ..deleteText(node!, textSelection.baseOffset - 1, 1) + // ..commit(); + // final newTextSelection = + // TextSelection.collapsed(offset: textSelection.baseOffset - 1); + // final selectionService = editorState.service.selectionService; + // final newOfset = + // selectable.getOffsetByTextSelection(newTextSelection); + // // selectionService.updateCursor(newOfset); + // return KeyEventResult.handled; + // } + // } + // } + // } + // } return KeyEventResult.ignored; }; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart new file mode 100644 index 0000000000..d1e89d393e --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart @@ -0,0 +1,46 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/extensions/path_extensions.dart'; +import 'package:flowy_editor/extensions/node_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) { + if (event.logicalKey != LogicalKeyboardKey.enter) { + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes.value; + final selection = editorState.service.selectionService.currentSelection; + if (selection == null || + nodes.length != 1 || + nodes.first is! TextNode || + !selection.isCollapsed) { + return KeyEventResult.ignored; + } + + final textNode = nodes.first as TextNode; + + if (textNode.selectable!.end() == selection.end) { + TransactionBuilder(editorState) + ..insertNode( + textNode.path.next, + TextNode.empty(), + ) + ..commit(); + return KeyEventResult.handled; + } else if (textNode.selectable!.start() == selection.start) { + TransactionBuilder(editorState) + ..insertNode( + textNode.path, + TextNode.empty(), + ) + ..commit(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart index 3eef8c1d1b..f424bcf314 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart @@ -1,6 +1,4 @@ -import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/extensions/object_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,21 +8,5 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { return KeyEventResult.ignored; } - final selectedNodes = editorState.selectedNodes; - if (selectedNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = selectedNodes.first.unwrapOrNull(); - final selectable = textNode?.key?.currentState?.unwrapOrNull(); - final textSelection = selectable?.getCurrentTextSelection(); - // if (textNode != null && selectable != null && textSelection != null) { - // final offset = selectable.getOffsetByTextSelection(textSelection); - // final rect = selectable.getCursorRect(offset); - // editorState.service.floatingToolbarService - // .showInOffset(rect.topLeft, textNode.layerLink); - // return KeyEventResult.handled; - // } - return KeyEventResult.ignored; }; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 19604b0227..07cf2ad902 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -17,7 +17,8 @@ mixin FlowySelectionService on State { /// Returns the currently selected [Node]s. /// /// The order of the return is determined according to the selected order. - List get currentSelectedNodes; + ValueNotifier> get currentSelectedNodes; + Selection? get currentSelection; /// ------------------ Selection ------------------------ @@ -95,7 +96,7 @@ class FlowySelection extends StatefulWidget { } class _FlowySelectionState extends State - with FlowySelectionService { + with FlowySelectionService, WidgetsBindingObserver { final _cursorKey = GlobalKey(debugLabel: 'cursor'); final List _selectionOverlays = []; @@ -112,12 +113,37 @@ class _FlowySelectionState extends State EditorState get editorState => widget.editorState; @override - List currentSelectedNodes = []; + Selection? currentSelection; + + @override + ValueNotifier> currentSelectedNodes = ValueNotifier([]); @override List getNodesInSelection(Selection selection) => _selectedNodesInSelection(editorState.document.root, selection); + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + + // Need to refresh the selection when the metrics changed. + if (currentSelection != null) { + updateSelection(currentSelection!); + } + } + + @override + void dispose() { + super.dispose(); + } + @override Widget build(BuildContext context) { return RawGestureDetector( @@ -136,8 +162,8 @@ class _FlowySelectionState extends State TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), - (recongizer) { - recongizer.onTapDown = _onTapDown; + (recognizer) { + recognizer.onTapDown = _onTapDown; }, ) }, @@ -151,8 +177,10 @@ class _FlowySelectionState extends State // cursor if (selection.isCollapsed) { + debugPrint('Update cursor'); _updateCursor(selection.start); } else { + debugPrint('Update selection'); _updateSelection(selection); } } @@ -167,9 +195,9 @@ class _FlowySelectionState extends State if (end != null) { return computeNodesInRange(editorState.document.root, start, end); } else { - final reuslt = computeNodeInOffset(editorState.document.root, start); - if (reuslt != null) { - return [reuslt]; + final result = computeNodeInOffset(editorState.document.root, start); + if (result != null) { + return [result]; } } return []; @@ -271,6 +299,9 @@ class _FlowySelectionState extends State panEndOffset = details.globalPosition; final nodes = getNodesInRange(panStartOffset!, panEndOffset!); + if (nodes.isEmpty) { + return; + } final first = nodes.first.selectable; final last = nodes.last.selectable; @@ -292,7 +323,8 @@ class _FlowySelectionState extends State } void _clearSelection() { - currentSelectedNodes = []; + currentSelection = null; + currentSelectedNodes.value = []; // clear selection _selectionOverlays @@ -302,7 +334,7 @@ class _FlowySelectionState extends State _cursorOverlays ..forEach((overlay) => overlay.remove()) ..clear(); - // clear floating shortcusts + // clear floating shortcuts editorState.service.floatingShortcutServiceKey.currentState ?.unwrapOrNull() ?.hide(); @@ -312,7 +344,8 @@ class _FlowySelectionState extends State final nodes = _selectedNodesInSelection(editorState.document.root, selection); - currentSelectedNodes = nodes; + currentSelection = selection; + currentSelectedNodes.value = nodes; var index = 0; for (final node in nodes) { @@ -374,7 +407,8 @@ class _FlowySelectionState extends State return; } - currentSelectedNodes = [node]; + currentSelection = Selection.collapsed(position); + currentSelectedNodes.value = [node]; final selectable = node.selectable; final rect = selectable?.getCursorRectInPosition(position); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart index f8cf4a9e5c..8fe715bbe7 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart @@ -14,6 +14,9 @@ class FlowyService { // keyboard service final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service'); + // input service + final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service'); + // floating shortcut service final floatingShortcutServiceKey = GlobalKey(debugLabel: 'flowy_floating_shortcut_service'); diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml index 74ca437e27..08e51118d1 100644 --- a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter + flutter_svg: ^1.1.1+1 provider: ^6.0.3 dev_dependencies: @@ -26,7 +27,8 @@ flutter: # To add assets to your package, add an assets section, like this: assets: - - document.json + - assets/images/uncheck.svg + - assets/images/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg #