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 =
+ '';
+ 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
#