Merge pull request #754 from LucasXu0/feat/flowy_editor_input_service

feat: implement bold text in toolbar service
This commit is contained in:
Nathan.fooo 2022-08-03 08:15:45 +08:00 committed by GitHub
commit afd87a41d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 349 additions and 143 deletions

View File

@ -144,7 +144,7 @@
}
],
"attributes": {
"subtype": "bullet-list"
"subtype": "bulleted-list"
}
},
{
@ -155,7 +155,7 @@
}
],
"attributes": {
"subtype": "bullet-list"
"subtype": "bulleted-list"
}
},
{
@ -170,7 +170,7 @@
}
],
"attributes": {
"subtype": "bullet-list"
"subtype": "bulleted-list"
}
},
{

View File

@ -116,13 +116,16 @@ class _MyHomePageState extends State<MyHomePage> {
_editorState = EditorState(
document: document,
);
return FlowyEditor(
key: editorKey,
editorState: _editorState,
keyEventHandlers: const [],
customBuilders: {
'image': ImageNodeBuilder(),
},
return Container(
padding: const EdgeInsets.only(left: 20, right: 20),
child: FlowyEditor(
key: editorKey,
editorState: _editorState,
keyEventHandlers: const [],
customBuilders: {
'image': ImageNodeBuilder(),
},
),
// shortcuts: [
// // TODO: this won't work, just a example for now.
// {

View File

@ -1,6 +1,23 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart';
/// 1. define your custom type in example.json
/// For example I need to define an image plugin, then I define type equals
/// "image", and add "image_src" into "attributes".
/// {
/// "type": "image",
/// "attributes", { "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" }
/// }
/// 2. create a class extends [NodeWidgetBuilder]
/// 3. override the function `Widget build(NodeWidgetContext<Node> context)`
/// and return a widget to render. The returned widget should be
/// a StatefulWidget and mixin with [Selectable].
///
/// 4. override the getter `nodeValidator`
/// to verify the data structure in [Node].
/// 5. register the plugin with `type` to `flowy_editor` in `main.dart`.
/// 6. Congratulations!
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {

View File

@ -26,7 +26,7 @@ Attributes? composeAttributes(Attributes? a, Attributes? b) {
a ??= {};
b ??= {};
final Attributes attributes = {};
attributes.addAll(b);
attributes.addAll(Map.from(b)..removeWhere((_, value) => value == null));
for (final entry in a.entries) {
if (!b.containsKey(entry.key)) {

View File

@ -89,7 +89,11 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
this.attributes['subtype'] != attributes['subtype'];
for (final attribute in attributes.entries) {
this.attributes[attribute.key] = attribute.value;
if (attribute.value == null) {
this.attributes.remove(attribute.key);
} else {
this.attributes[attribute.key] = attribute.value;
}
}
// Notify the new attributes
// if attributes contains 'subtype', should notify parent to rebuild node
@ -178,7 +182,7 @@ class TextNode extends Node {
}) : _delta = delta;
TextNode.empty()
: _delta = Delta([TextInsert('')]),
: _delta = Delta([TextInsert(' ')]),
super(
type: 'text',
children: LinkedList(),
@ -201,6 +205,19 @@ class TextNode extends Node {
return map;
}
TextNode copyWith({
String? type,
LinkedList<Node>? children,
Attributes? attributes,
Delta? delta,
}) =>
TextNode(
type: type ?? this.type,
children: children ?? this.children,
attributes: attributes ?? this.attributes,
delta: delta ?? this.delta,
);
// TODO: It's unneccesry to compute everytime.
String toRawString() =>
_delta.operations.whereType<TextInsert>().map((op) => op.content).join();

View File

@ -60,7 +60,12 @@ class EditorState {
for (final op in transaction.operations) {
_applyOperation(op);
}
updateCursorSelection(transaction.afterSelection);
// updateCursorSelection(transaction.afterSelection);
// FIXME: don't use delay
Future.delayed(const Duration(milliseconds: 16), () {
updateCursorSelection(transaction.afterSelection);
});
if (options.recordUndo) {
final undoItem = undoManager.getUndoHistoryItem();

View File

@ -1,18 +1,18 @@
import 'dart:collection';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/document/attributes.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/document/path.dart';
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/text_delta.dart';
import 'package:flowy_editor/document/attributes.dart';
import 'package:flowy_editor/document/selection.dart';
import './operation.dart';
import './transaction.dart';
import 'package:flowy_editor/document/text_delta.dart';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/operation/operation.dart';
import 'package:flowy_editor/operation/transaction.dart';
/// A [TransactionBuilder] is used to build the transaction from the state.
/// It will save make a snapshot of the cursor selection state automatically.
/// The cursor can be resoted if the transaction is undo.
/// The cursor can be resorted if the transaction is undo.
class TransactionBuilder {
final List<Operation> operations = [];
@ -70,7 +70,7 @@ class TransactionBuilder {
}
textEdit(TextNode node, Delta Function() f) {
beforeSelection = state.service.selectionService.currentSelection;
beforeSelection = state.cursorSelection;
final path = node.path;
final delta = f();

View File

@ -21,7 +21,7 @@ class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
NodeValidator<Node> get nodeValidator => ((node) {
return node.attributes.containsKey(StyleKey.check);
return node.attributes.containsKey(StyleKey.checkbox);
});
}

View File

@ -74,7 +74,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
final cursorHeight = widget.cursorHeight ??
_renderParagraph.getFullHeightForCaret(textPosition) ??
5.0; // default height
18.0; // default height
return Rect.fromLTWH(
cursorOffset.dx - (widget.cursorWidth / 2),
cursorOffset.dy,

View File

@ -25,26 +25,49 @@ class StyleKey {
static String font = 'font';
static String href = 'href';
static String quote = 'quote';
static String list = 'list';
static String number = 'number';
static String todo = 'todo';
static String code = 'code';
static String subtype = 'subtype';
static String check = 'checkbox';
static String heading = 'heading';
static String h1 = 'h1';
static String h2 = 'h2';
static String h3 = 'h3';
static String h4 = 'h4';
static String h5 = 'h5';
static String h6 = 'h6';
static String bulletedList = 'bulleted-list';
static String numberList = 'number-list';
static String quote = 'quote';
static String checkbox = 'checkbox';
static String code = 'code';
static String number = 'number';
static List<String> partialStyleKeys = [
StyleKey.bold,
StyleKey.italic,
StyleKey.underline,
StyleKey.strikethrough,
];
static List<String> globalStyleKeys = [
StyleKey.heading,
StyleKey.checkbox,
StyleKey.bulletedList,
StyleKey.numberList,
StyleKey.quote,
StyleKey.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,
StyleKey.h1: baseFontSize + 15,
StyleKey.h2: baseFontSize + 12,
StyleKey.h3: baseFontSize + 9,
StyleKey.h4: baseFontSize + 6,
StyleKey.h5: baseFontSize + 3,
StyleKey.h6: baseFontSize,
};
extension NodeAttributesExtensions on Attributes {
@ -73,13 +96,6 @@ extension NodeAttributesExtensions on Attributes {
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];
@ -87,13 +103,6 @@ extension NodeAttributesExtensions on Attributes {
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];
@ -102,8 +111,8 @@ extension NodeAttributesExtensions on Attributes {
}
bool get check {
if (containsKey(StyleKey.check) && this[StyleKey.check] is bool) {
return this[StyleKey.check];
if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) {
return this[StyleKey.checkbox];
}
return false;
}

View File

@ -1,43 +1,39 @@
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/infra/flowy_svg.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart';
typedef ToolbarEventHandler = void Function(
EditorState editorState, String eventName);
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/infra/flowy_svg.dart';
import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart';
typedef ToolbarEventHandlers = List<Map<String, ToolbarEventHandler>>;
ToolbarEventHandlers defaultToolbarEventHandlers = [
{
'bold': ((editorState, eventName) {}),
'italic': ((editorState, eventName) {}),
'strikethrough': ((editorState, eventName) {}),
'underline': ((editorState, eventName) {}),
'quote': ((editorState, eventName) {}),
'number_list': ((editorState, eventName) {}),
'bulleted_list': ((editorState, eventName) {}),
}
];
typedef ToolbarEventHandler = void Function(EditorState editorState);
ToolbarEventHandlers defaultListToolbarEventHandlers = [
{
'h1': ((editorState, eventName) {}),
},
{
'h2': ((editorState, eventName) {}),
},
{
'h3': ((editorState, eventName) {}),
},
{
'bulleted_list': ((editorState, eventName) {}),
},
{
'quote': ((editorState, eventName) {}),
}
typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
ToolbarEventHandlers defaultToolbarEventHandlers = {
'bold': (editorState) => formatBold(editorState),
'italic': (editorState) => formatItalic(editorState),
'strikethrough': (editorState) => formatStrikethrough(editorState),
'underline': (editorState) => formatUnderline(editorState),
'quote': (editorState) => formatQuote(editorState),
'number_list': (editorState) {},
'bulleted_list': (editorState) => formatBulletedList(editorState),
'Text': (editorState) => formatText(editorState),
'H1': (editorState) => formatHeading(editorState, StyleKey.h1),
'H2': (editorState) => formatHeading(editorState, StyleKey.h2),
'H3': (editorState) => formatHeading(editorState, StyleKey.h3),
};
List<String> defaultListToolbarEventNames = [
'Text',
'H1',
'H2',
'H3',
// 'B-List',
// 'N-List',
];
class ToolbarWidget extends StatefulWidget {
ToolbarWidget({
const ToolbarWidget({
Key? key,
required this.editorState,
required this.layerLink,
@ -137,7 +133,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
preferBelow: false,
message: name,
child: GestureDetector(
onTap: onTap ?? () => debugPrint('toolbar tap $name'),
onTap: onTap ?? () => _onTap(name),
child: SizedBox.fromSize(
size: width != null
? Size(width, toolbarHeight)
@ -154,9 +150,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
void _onTapListToolbar(BuildContext context) {
// TODO: implement more detailed UI.
final items = defaultListToolbarEventHandlers
.map((handler) => handler.keys.first)
.toList(growable: false);
final items = defaultListToolbarEventNames;
final renderBox =
_listToolbarKey.currentContext?.findRenderObject() as RenderBox;
final offset = renderBox
@ -198,7 +192,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
),
),
onTap: () {
debugPrint('tap on $index');
_onTap(items[index]);
},
);
}),
@ -210,6 +204,14 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
Overlay.of(context)?.insert(_listToolbarOverlay!);
}
void _onTap(String eventName) {
if (defaultToolbarEventHandlers.containsKey(eventName)) {
defaultToolbarEventHandlers[eventName]!(widget.editorState);
return;
}
assert(false, 'Could not find the event handler for $eventName');
}
void _onSelectionChange() {
_listToolbarOverlay?.remove();
_listToolbarOverlay = null;

View File

@ -1,9 +1,103 @@
import 'package:flowy_editor/document/attributes.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/extensions/text_node_extensions.dart';
import 'package:flowy_editor/operation/transaction_builder.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
bool formatRichTextStyle(
EditorState editorState, Map<String, dynamic> attributes) {
void formatText(EditorState editorState) {
formatTextNodes(editorState, {});
}
void formatHeading(EditorState editorState, String heading) {
formatTextNodes(editorState, {
StyleKey.subtype: StyleKey.heading,
StyleKey.heading: heading,
});
}
void formatQuote(EditorState editorState) {
formatTextNodes(editorState, {
StyleKey.subtype: StyleKey.quote,
});
}
void formatCheckbox(EditorState editorState) {
formatTextNodes(editorState, {
StyleKey.subtype: StyleKey.checkbox,
StyleKey.checkbox: false,
});
}
void formatBulletedList(EditorState editorState) {
formatTextNodes(editorState, {
StyleKey.subtype: StyleKey.bulletedList,
});
}
bool formatTextNodes(EditorState editorState, Attributes attributes) {
final nodes = editorState.service.selectionService.currentSelectedNodes.value;
final textNodes = nodes.whereType<TextNode>().toList();
if (textNodes.isEmpty) {
return false;
}
final builder = TransactionBuilder(editorState);
for (final textNode in textNodes) {
builder.updateNode(
textNode,
Attributes.fromIterable(
StyleKey.globalStyleKeys,
value: (_) => null,
)..addAll(attributes),
);
}
builder.commit();
return true;
}
bool formatBold(EditorState editorState) {
return formatRichTextPartialStyle(editorState, StyleKey.bold);
}
bool formatItalic(EditorState editorState) {
return formatRichTextPartialStyle(editorState, StyleKey.italic);
}
bool formatUnderline(EditorState editorState) {
return formatRichTextPartialStyle(editorState, StyleKey.underline);
}
bool formatStrikethrough(EditorState editorState) {
return formatRichTextPartialStyle(editorState, StyleKey.strikethrough);
}
bool formatRichTextPartialStyle(EditorState editorState, String styleKey) {
final selection = editorState.service.selectionService.currentSelection;
final nodes = editorState.service.selectionService.currentSelectedNodes.value;
final textNodes = nodes.whereType<TextNode>().toList(growable: false);
if (selection == null || textNodes.isEmpty) {
return false;
}
bool value = !textNodes.allSatisfyInSelection(styleKey, selection);
Attributes attributes = {
styleKey: value,
};
if (styleKey == StyleKey.underline && value) {
attributes[StyleKey.strikethrough] = null;
} else if (styleKey == StyleKey.strikethrough && value) {
attributes[StyleKey.underline] = null;
}
return formatRichTextStyle(editorState, attributes);
}
bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
final selection = editorState.service.selectionService.currentSelection;
final nodes = editorState.service.selectionService.currentSelectedNodes.value;
final textNodes = nodes.whereType<TextNode>().toList();
@ -17,29 +111,38 @@ bool formatRichTextStyle(
// 1. All nodes are text nodes.
// 2. The first node is not TextNode.
// 3. The last node is not TextNode.
for (var i = 0; i < textNodes.length; i++) {
final textNode = textNodes[i];
if (i == 0 && textNode == nodes.first) {
builder.formatText(
textNode,
selection.start.offset,
textNode.toRawString().length - selection.start.offset,
attributes,
);
} else if (i == textNodes.length - 1 && textNode == nodes.last) {
builder.formatText(
textNode,
0,
selection.end.offset,
attributes,
);
} else {
builder.formatText(
textNode,
0,
textNode.toRawString().length,
attributes,
);
if (nodes.length == textNodes.length && textNodes.length == 1) {
builder.formatText(
textNodes.first,
selection.start.offset,
selection.end.offset - selection.start.offset,
attributes,
);
} else {
for (var i = 0; i < textNodes.length; i++) {
final textNode = textNodes[i];
if (i == 0 && textNode == nodes.first) {
builder.formatText(
textNode,
selection.start.offset,
textNode.toRawString().length - selection.start.offset,
attributes,
);
} else if (i == textNodes.length - 1 && textNode == nodes.last) {
builder.formatText(
textNode,
0,
selection.end.offset,
attributes,
);
} else {
builder.formatText(
textNode,
0,
textNode.toRawString().length,
attributes,
);
}
}
}

View File

@ -25,7 +25,7 @@ NodeWidgetBuilders defaultBuilders = {
'text': RichTextNodeWidgetBuilder(),
'text/checkbox': CheckboxNodeWidgetBuilder(),
'text/heading': HeadingTextNodeWidgetBuilder(),
'text/bullet-list': BulletedListTextNodeWidgetBuilder(),
'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
'text/number-list': NumberListTextNodeWidgetBuilder(),
'text/quote': QuotedTextNodeWidgetBuilder(),
};

View File

@ -1,12 +1,17 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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/document/text_delta.dart';
import 'package:flowy_editor/extensions/node_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flowy_editor/extensions/path_extensions.dart';
import 'package:flowy_editor/operation/transaction_builder.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.enter) {
@ -23,20 +28,63 @@ FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
}
final textNode = nodes.first as TextNode;
if (textNode.selectable!.end() == selection.end) {
TransactionBuilder(editorState)
..insertNode(
textNode.path.next,
TextNode.empty(),
)
..commit();
if (textNode.subtype != null && textNode.delta.length == 0) {
TransactionBuilder(editorState)
..deleteNode(textNode)
..insertNode(
textNode.path,
textNode.copyWith(
children: LinkedList(),
delta: Delta([TextInsert('')]),
attributes: {},
),
)
..afterSelection = Selection.collapsed(
Position(
path: textNode.path,
offset: 0,
),
)
..commit();
} else {
final needCopyAttributes = StyleKey.globalStyleKeys
.where((key) => key != StyleKey.heading)
.contains(textNode.subtype);
TransactionBuilder(editorState)
..insertNode(
textNode.path.next,
textNode.copyWith(
children: LinkedList(),
delta: Delta([TextInsert('')]),
attributes: needCopyAttributes ? textNode.attributes : {},
),
)
..afterSelection = Selection.collapsed(
Position(
path: textNode.path.next,
offset: 0,
),
)
..commit();
}
return KeyEventResult.handled;
} else if (textNode.selectable!.start() == selection.start) {
TransactionBuilder(editorState)
..insertNode(
textNode.path,
TextNode.empty(),
textNode.copyWith(
children: LinkedList(),
delta: Delta([TextInsert('')]),
attributes: {},
),
)
..afterSelection = Selection.collapsed(
Position(
path: textNode.path.next,
offset: 0,
),
)
..commit();
return KeyEventResult.handled;

View File

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/extensions/text_node_extensions.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
@ -23,9 +21,7 @@ FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
// bold
case 'B':
case 'b':
formatRichTextStyle(editorState, {
StyleKey.bold: !textNodes.allSatisfyBoldInSelection(selection),
});
formatBold(editorState);
return KeyEventResult.handled;
default:
break;

View File

@ -233,6 +233,9 @@ class _FlowySelectionState extends State<FlowySelection>
@override
void dispose() {
clearSelection();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@ -455,7 +458,7 @@ class _FlowySelectionState extends State<FlowySelection>
..forEach((overlay) => overlay.remove())
..clear();
// clear toolbar
editorState.service.toolbarService.hide();
editorState.service.toolbarService?.hide();
}
void _updateSelection(Selection selection) {
@ -526,7 +529,7 @@ class _FlowySelectionState extends State<FlowySelection>
if (topmostRect != null && layerLink != null) {
editorState.service.toolbarService
.showInOffset(topmostRect.topLeft, layerLink);
?.showInOffset(topmostRect.topLeft, layerLink);
}
}

View File

@ -23,9 +23,11 @@ class FlowyService {
// toolbar service
final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
ToolbarService get toolbarService {
assert(toolbarServiceKey.currentState != null &&
toolbarServiceKey.currentState is ToolbarService);
return toolbarServiceKey.currentState! as ToolbarService;
ToolbarService? get toolbarService {
if (toolbarServiceKey.currentState != null &&
toolbarServiceKey.currentState is ToolbarService) {
return toolbarServiceKey.currentState! as ToolbarService;
}
return null;
}
}

View File

@ -35,7 +35,7 @@ class _FlowyToolbarState extends State<FlowyToolbar> with ToolbarService {
editorState: widget.editorState,
layerLink: layerLink,
offset: offset.translate(0, -37.0),
handlers: const [],
handlers: const {},
),
);
Overlay.of(context)?.insert(_toolbarOverlay!);

View File

@ -8,6 +8,7 @@ import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/document/state_tree.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('transform path', () {
test('transform path changed', () {
expect(transformPath([0, 1], [0, 1]), [0, 2]);
@ -87,7 +88,7 @@ void main() {
"path": [0],
"nodes": [item1.toJson()],
}
],
]
});
});
test("delete", () {