Merge pull request #726 from LucasXu0/feat/flowy_editor_input_service

feat: add input service to handle text editing.
This commit is contained in:
Lucas.Xu 2022-07-28 14:26:15 +08:00 committed by GitHub
commit 6b59050ef3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1920 additions and 544 deletions

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="12" height="12" rx="4" fill="#00BCF0"/>
<path d="M6 8L7.61538 9.5L10.5 6.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="4" height="4" rx="2" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 166 B

View File

@ -0,0 +1,3 @@
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="160" x="80" y="20" fill="#00BCF0"/>
</svg>

After

Width:  |  Height:  |  Size: 135 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="3.5" stroke="#BDBDBD"/>
</svg>

After

Width:  |  Height:  |  Size: 176 B

View File

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

View File

@ -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
}
}
]
}
}

View File

@ -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<Widget> children;
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56.0,
height: 56.0,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4.0,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
),
),
),
),
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for (var i = 0, angleInDegrees = 0.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
child: const Icon(Icons.create),
),
),
),
);
}
}
@immutable
class _ExpandingActionButton extends StatelessWidget {
const _ExpandingActionButton({
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
});
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
@immutable
class ActionButton extends StatelessWidget {
const ActionButton({
super.key,
this.onPressed,
required this.icon,
});
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.secondary,
elevation: 4.0,
child: IconButton(
onPressed: onPressed,
icon: icon,
color: theme.colorScheme.onSecondary,
),
);
}
}
@immutable
class FakeItem extends StatelessWidget {
const FakeItem({
super.key,
required this.isBig,
});
final bool isBig;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0),
height: isBig ? 128.0 : 36.0,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.shade300,
),
);
}
}

View File

@ -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<MyHomePage> {
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<MyHomePage> {
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: FutureBuilder<String>(
future: rootBundle.loadString('assets/document.json'),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else {
final data = Map<String, Object>.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<String>(
future: rootBundle.loadString('assets/example.json'),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else {
final data = Map<String, Object>.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(),
);
}
}

View File

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

View File

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

View File

@ -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<String, dynamic> 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<TextEditingDelta> 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<TextInsert>()
// .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),
// );
// }

View File

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

View File

@ -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<String, dynamic> 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<TextEditingDelta> 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<TextInsert>()
.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),
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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';

View File

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

View File

@ -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';

View File

@ -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 =
'<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><text x="30" y="150" fill="black" font-size="160">$number.</text></svg>';
return SizedBox.fromSize(
size: size,
child: SvgPicture.string(numberText),
);
}
return Container();
}
}

View File

@ -26,14 +26,14 @@ class NodeWidgetBuilder<T extends Node> {
/// 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<T extends Node> {
'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<T>(
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<T>(
builder: ((context, value, child) {
debugPrint('Node is rebuilding...');
return build(context);
}),
),
),
);
}

View File

@ -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<FlowyRichText> createState() => _FlowyRichTextState();
}
class _FlowyRichTextState extends State<FlowyRichText> 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<Rect> 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<RenderBox>();
if (renderBox != null) {
return renderBox.localToGlobal(Offset.zero) & renderBox.size;
}
return Rect.zero;
}
TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan(
children: _textSpan.children
?.whereType<TextSpan>()
.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<TextInsert>()
.map((insert) => RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
).toTextSpan())
.toList(growable: false));
}

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
///
mixin Selectable<T extends StatefulWidget> on State<T> {
/// 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<T extends StatefulWidget> on State<T> {
///
/// 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;
}

View File

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

View File

@ -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<TextEditingDelta> 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<FlowyInput> createState() => _FlowyInputState();
}
class _FlowyInputState extends State<FlowyInput>
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<TextEditingDelta> 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<String, dynamic> 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<TextEditingDelta> 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<TextNode>();
final text = textNodes.fold<String>(
'', (sum, textNode) => '$sum${textNode.toRawString()}\n');
attach(
TextEditingValue(
text: text,
selection: TextSelection(
baseOffset: selection.start.offset,
extentOffset: selection.end.offset,
),
),
);
} else {
close();
}
}
}

View File

@ -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<TextNode>();
final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
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<Selectable>();
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<TextNode>();
// final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
// 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<Selectable>();
// 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;
};

View File

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

View File

@ -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<TextNode>();
final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
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;
};

View File

@ -17,7 +17,8 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
/// Returns the currently selected [Node]s.
///
/// The order of the return is determined according to the selected order.
List<Node> get currentSelectedNodes;
ValueNotifier<List<Node>> get currentSelectedNodes;
Selection? get currentSelection;
/// ------------------ Selection ------------------------
@ -95,7 +96,7 @@ class FlowySelection extends StatefulWidget {
}
class _FlowySelectionState extends State<FlowySelection>
with FlowySelectionService {
with FlowySelectionService, WidgetsBindingObserver {
final _cursorKey = GlobalKey(debugLabel: 'cursor');
final List<OverlayEntry> _selectionOverlays = [];
@ -112,12 +113,37 @@ class _FlowySelectionState extends State<FlowySelection>
EditorState get editorState => widget.editorState;
@override
List<Node> currentSelectedNodes = [];
Selection? currentSelection;
@override
ValueNotifier<List<Node>> currentSelectedNodes = ValueNotifier([]);
@override
List<Node> 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<FlowySelection>
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recongizer) {
recongizer.onTapDown = _onTapDown;
(recognizer) {
recognizer.onTapDown = _onTapDown;
},
)
},
@ -151,8 +177,10 @@ class _FlowySelectionState extends State<FlowySelection>
// cursor
if (selection.isCollapsed) {
debugPrint('Update cursor');
_updateCursor(selection.start);
} else {
debugPrint('Update selection');
_updateSelection(selection);
}
}
@ -167,9 +195,9 @@ class _FlowySelectionState extends State<FlowySelection>
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<FlowySelection>
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<FlowySelection>
}
void _clearSelection() {
currentSelectedNodes = [];
currentSelection = null;
currentSelectedNodes.value = [];
// clear selection
_selectionOverlays
@ -302,7 +334,7 @@ class _FlowySelectionState extends State<FlowySelection>
_cursorOverlays
..forEach((overlay) => overlay.remove())
..clear();
// clear floating shortcusts
// clear floating shortcuts
editorState.service.floatingShortcutServiceKey.currentState
?.unwrapOrNull<FlowyFloatingShortcutService>()
?.hide();
@ -312,7 +344,8 @@ class _FlowySelectionState extends State<FlowySelection>
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<FlowySelection>
return;
}
currentSelectedNodes = [node];
currentSelection = Selection.collapsed(position);
currentSelectedNodes.value = [node];
final selectable = node.selectable;
final rect = selectable?.getCursorRectInPosition(position);

View File

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

View File

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