mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #754 from LucasXu0/feat/flowy_editor_input_service
feat: implement bold text in toolbar service
This commit is contained in:
commit
afd87a41d4
@ -144,7 +144,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"subtype": "bullet-list"
|
"subtype": "bulleted-list"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -155,7 +155,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"subtype": "bullet-list"
|
"subtype": "bulleted-list"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -170,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"subtype": "bullet-list"
|
"subtype": "bulleted-list"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -116,13 +116,16 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
_editorState = EditorState(
|
_editorState = EditorState(
|
||||||
document: document,
|
document: document,
|
||||||
);
|
);
|
||||||
return FlowyEditor(
|
return Container(
|
||||||
key: editorKey,
|
padding: const EdgeInsets.only(left: 20, right: 20),
|
||||||
editorState: _editorState,
|
child: FlowyEditor(
|
||||||
keyEventHandlers: const [],
|
key: editorKey,
|
||||||
customBuilders: {
|
editorState: _editorState,
|
||||||
'image': ImageNodeBuilder(),
|
keyEventHandlers: const [],
|
||||||
},
|
customBuilders: {
|
||||||
|
'image': ImageNodeBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
// shortcuts: [
|
// shortcuts: [
|
||||||
// // TODO: this won't work, just a example for now.
|
// // TODO: this won't work, just a example for now.
|
||||||
// {
|
// {
|
||||||
|
@ -1,6 +1,23 @@
|
|||||||
import 'package:flowy_editor/flowy_editor.dart';
|
import 'package:flowy_editor/flowy_editor.dart';
|
||||||
import 'package:flutter/material.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> {
|
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
|
||||||
@override
|
@override
|
||||||
Widget build(NodeWidgetContext<Node> context) {
|
Widget build(NodeWidgetContext<Node> context) {
|
||||||
|
@ -26,7 +26,7 @@ Attributes? composeAttributes(Attributes? a, Attributes? b) {
|
|||||||
a ??= {};
|
a ??= {};
|
||||||
b ??= {};
|
b ??= {};
|
||||||
final Attributes attributes = {};
|
final Attributes attributes = {};
|
||||||
attributes.addAll(b);
|
attributes.addAll(Map.from(b)..removeWhere((_, value) => value == null));
|
||||||
|
|
||||||
for (final entry in a.entries) {
|
for (final entry in a.entries) {
|
||||||
if (!b.containsKey(entry.key)) {
|
if (!b.containsKey(entry.key)) {
|
||||||
|
@ -89,7 +89,11 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
this.attributes['subtype'] != attributes['subtype'];
|
this.attributes['subtype'] != attributes['subtype'];
|
||||||
|
|
||||||
for (final attribute in attributes.entries) {
|
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
|
// Notify the new attributes
|
||||||
// if attributes contains 'subtype', should notify parent to rebuild node
|
// if attributes contains 'subtype', should notify parent to rebuild node
|
||||||
@ -178,7 +182,7 @@ class TextNode extends Node {
|
|||||||
}) : _delta = delta;
|
}) : _delta = delta;
|
||||||
|
|
||||||
TextNode.empty()
|
TextNode.empty()
|
||||||
: _delta = Delta([TextInsert('')]),
|
: _delta = Delta([TextInsert(' ')]),
|
||||||
super(
|
super(
|
||||||
type: 'text',
|
type: 'text',
|
||||||
children: LinkedList(),
|
children: LinkedList(),
|
||||||
@ -201,6 +205,19 @@ class TextNode extends Node {
|
|||||||
return map;
|
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.
|
// TODO: It's unneccesry to compute everytime.
|
||||||
String toRawString() =>
|
String toRawString() =>
|
||||||
_delta.operations.whereType<TextInsert>().map((op) => op.content).join();
|
_delta.operations.whereType<TextInsert>().map((op) => op.content).join();
|
||||||
|
@ -60,7 +60,12 @@ class EditorState {
|
|||||||
for (final op in transaction.operations) {
|
for (final op in transaction.operations) {
|
||||||
_applyOperation(op);
|
_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) {
|
if (options.recordUndo) {
|
||||||
final undoItem = undoManager.getUndoHistoryItem();
|
final undoItem = undoManager.getUndoHistoryItem();
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import 'dart:collection';
|
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/node.dart';
|
||||||
import 'package:flowy_editor/document/path.dart';
|
import 'package:flowy_editor/document/path.dart';
|
||||||
import 'package:flowy_editor/document/position.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 'package:flowy_editor/document/selection.dart';
|
||||||
|
import 'package:flowy_editor/document/text_delta.dart';
|
||||||
import './operation.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import './transaction.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.
|
/// A [TransactionBuilder] is used to build the transaction from the state.
|
||||||
/// It will save make a snapshot of the cursor selection state automatically.
|
/// 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 {
|
class TransactionBuilder {
|
||||||
final List<Operation> operations = [];
|
final List<Operation> operations = [];
|
||||||
@ -70,7 +70,7 @@ class TransactionBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
textEdit(TextNode node, Delta Function() f) {
|
textEdit(TextNode node, Delta Function() f) {
|
||||||
beforeSelection = state.service.selectionService.currentSelection;
|
beforeSelection = state.cursorSelection;
|
||||||
final path = node.path;
|
final path = node.path;
|
||||||
|
|
||||||
final delta = f();
|
final delta = f();
|
||||||
|
@ -21,7 +21,7 @@ class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
NodeValidator<Node> get nodeValidator => ((node) {
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
return node.attributes.containsKey(StyleKey.check);
|
return node.attributes.containsKey(StyleKey.checkbox);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
|||||||
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
|
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
|
||||||
final cursorHeight = widget.cursorHeight ??
|
final cursorHeight = widget.cursorHeight ??
|
||||||
_renderParagraph.getFullHeightForCaret(textPosition) ??
|
_renderParagraph.getFullHeightForCaret(textPosition) ??
|
||||||
5.0; // default height
|
18.0; // default height
|
||||||
return Rect.fromLTWH(
|
return Rect.fromLTWH(
|
||||||
cursorOffset.dx - (widget.cursorWidth / 2),
|
cursorOffset.dx - (widget.cursorWidth / 2),
|
||||||
cursorOffset.dy,
|
cursorOffset.dy,
|
||||||
|
@ -25,26 +25,49 @@ class StyleKey {
|
|||||||
static String font = 'font';
|
static String font = 'font';
|
||||||
static String href = 'href';
|
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 subtype = 'subtype';
|
||||||
static String check = 'checkbox';
|
|
||||||
static String heading = 'heading';
|
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;
|
double baseFontSize = 16.0;
|
||||||
// TODO: customize.
|
// TODO: customize.
|
||||||
Map<String, double> headingToFontSize = {
|
Map<String, double> headingToFontSize = {
|
||||||
'h1': baseFontSize + 15,
|
StyleKey.h1: baseFontSize + 15,
|
||||||
'h2': baseFontSize + 12,
|
StyleKey.h2: baseFontSize + 12,
|
||||||
'h3': baseFontSize + 9,
|
StyleKey.h3: baseFontSize + 9,
|
||||||
'h4': baseFontSize + 6,
|
StyleKey.h4: baseFontSize + 6,
|
||||||
'h5': baseFontSize + 3,
|
StyleKey.h5: baseFontSize + 3,
|
||||||
'h6': baseFontSize,
|
StyleKey.h6: baseFontSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
extension NodeAttributesExtensions on Attributes {
|
extension NodeAttributesExtensions on Attributes {
|
||||||
@ -73,13 +96,6 @@ extension NodeAttributesExtensions on Attributes {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get list {
|
|
||||||
if (containsKey(StyleKey.list) && this[StyleKey.list] is String) {
|
|
||||||
return this[StyleKey.list];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int? get number {
|
int? get number {
|
||||||
if (containsKey(StyleKey.number) && this[StyleKey.number] is int) {
|
if (containsKey(StyleKey.number) && this[StyleKey.number] is int) {
|
||||||
return this[StyleKey.number];
|
return this[StyleKey.number];
|
||||||
@ -87,13 +103,6 @@ extension NodeAttributesExtensions on Attributes {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get todo {
|
|
||||||
if (containsKey(StyleKey.todo) && this[StyleKey.todo] is bool) {
|
|
||||||
return this[StyleKey.todo];
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get code {
|
bool get code {
|
||||||
if (containsKey(StyleKey.code) && this[StyleKey.code] == true) {
|
if (containsKey(StyleKey.code) && this[StyleKey.code] == true) {
|
||||||
return this[StyleKey.code];
|
return this[StyleKey.code];
|
||||||
@ -102,8 +111,8 @@ extension NodeAttributesExtensions on Attributes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get check {
|
bool get check {
|
||||||
if (containsKey(StyleKey.check) && this[StyleKey.check] is bool) {
|
if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) {
|
||||||
return this[StyleKey.check];
|
return this[StyleKey.checkbox];
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -1,43 +1,39 @@
|
|||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
|
||||||
import 'package:flowy_editor/infra/flowy_svg.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
typedef ToolbarEventHandler = void Function(
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
EditorState editorState, String eventName);
|
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>>;
|
typedef ToolbarEventHandler = void Function(EditorState editorState);
|
||||||
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) {}),
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
ToolbarEventHandlers defaultListToolbarEventHandlers = [
|
typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
|
||||||
{
|
|
||||||
'h1': ((editorState, eventName) {}),
|
ToolbarEventHandlers defaultToolbarEventHandlers = {
|
||||||
},
|
'bold': (editorState) => formatBold(editorState),
|
||||||
{
|
'italic': (editorState) => formatItalic(editorState),
|
||||||
'h2': ((editorState, eventName) {}),
|
'strikethrough': (editorState) => formatStrikethrough(editorState),
|
||||||
},
|
'underline': (editorState) => formatUnderline(editorState),
|
||||||
{
|
'quote': (editorState) => formatQuote(editorState),
|
||||||
'h3': ((editorState, eventName) {}),
|
'number_list': (editorState) {},
|
||||||
},
|
'bulleted_list': (editorState) => formatBulletedList(editorState),
|
||||||
{
|
'Text': (editorState) => formatText(editorState),
|
||||||
'bulleted_list': ((editorState, eventName) {}),
|
'H1': (editorState) => formatHeading(editorState, StyleKey.h1),
|
||||||
},
|
'H2': (editorState) => formatHeading(editorState, StyleKey.h2),
|
||||||
{
|
'H3': (editorState) => formatHeading(editorState, StyleKey.h3),
|
||||||
'quote': ((editorState, eventName) {}),
|
};
|
||||||
}
|
|
||||||
|
List<String> defaultListToolbarEventNames = [
|
||||||
|
'Text',
|
||||||
|
'H1',
|
||||||
|
'H2',
|
||||||
|
'H3',
|
||||||
|
// 'B-List',
|
||||||
|
// 'N-List',
|
||||||
];
|
];
|
||||||
|
|
||||||
class ToolbarWidget extends StatefulWidget {
|
class ToolbarWidget extends StatefulWidget {
|
||||||
ToolbarWidget({
|
const ToolbarWidget({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.editorState,
|
required this.editorState,
|
||||||
required this.layerLink,
|
required this.layerLink,
|
||||||
@ -137,7 +133,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
|
|||||||
preferBelow: false,
|
preferBelow: false,
|
||||||
message: name,
|
message: name,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap ?? () => debugPrint('toolbar tap $name'),
|
onTap: onTap ?? () => _onTap(name),
|
||||||
child: SizedBox.fromSize(
|
child: SizedBox.fromSize(
|
||||||
size: width != null
|
size: width != null
|
||||||
? Size(width, toolbarHeight)
|
? Size(width, toolbarHeight)
|
||||||
@ -154,9 +150,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
|
|||||||
|
|
||||||
void _onTapListToolbar(BuildContext context) {
|
void _onTapListToolbar(BuildContext context) {
|
||||||
// TODO: implement more detailed UI.
|
// TODO: implement more detailed UI.
|
||||||
final items = defaultListToolbarEventHandlers
|
final items = defaultListToolbarEventNames;
|
||||||
.map((handler) => handler.keys.first)
|
|
||||||
.toList(growable: false);
|
|
||||||
final renderBox =
|
final renderBox =
|
||||||
_listToolbarKey.currentContext?.findRenderObject() as RenderBox;
|
_listToolbarKey.currentContext?.findRenderObject() as RenderBox;
|
||||||
final offset = renderBox
|
final offset = renderBox
|
||||||
@ -198,7 +192,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
debugPrint('tap on $index');
|
_onTap(items[index]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@ -210,6 +204,14 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
|
|||||||
Overlay.of(context)?.insert(_listToolbarOverlay!);
|
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() {
|
void _onSelectionChange() {
|
||||||
_listToolbarOverlay?.remove();
|
_listToolbarOverlay?.remove();
|
||||||
_listToolbarOverlay = null;
|
_listToolbarOverlay = null;
|
||||||
|
@ -1,9 +1,103 @@
|
|||||||
|
import 'package:flowy_editor/document/attributes.dart';
|
||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/editor_state.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/operation/transaction_builder.dart';
|
||||||
|
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
|
||||||
|
|
||||||
bool formatRichTextStyle(
|
void formatText(EditorState editorState) {
|
||||||
EditorState editorState, Map<String, dynamic> attributes) {
|
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 selection = editorState.service.selectionService.currentSelection;
|
||||||
final nodes = editorState.service.selectionService.currentSelectedNodes.value;
|
final nodes = editorState.service.selectionService.currentSelectedNodes.value;
|
||||||
final textNodes = nodes.whereType<TextNode>().toList();
|
final textNodes = nodes.whereType<TextNode>().toList();
|
||||||
@ -17,29 +111,38 @@ bool formatRichTextStyle(
|
|||||||
// 1. All nodes are text nodes.
|
// 1. All nodes are text nodes.
|
||||||
// 2. The first node is not TextNode.
|
// 2. The first node is not TextNode.
|
||||||
// 3. The last node is not TextNode.
|
// 3. The last node is not TextNode.
|
||||||
for (var i = 0; i < textNodes.length; i++) {
|
if (nodes.length == textNodes.length && textNodes.length == 1) {
|
||||||
final textNode = textNodes[i];
|
builder.formatText(
|
||||||
if (i == 0 && textNode == nodes.first) {
|
textNodes.first,
|
||||||
builder.formatText(
|
selection.start.offset,
|
||||||
textNode,
|
selection.end.offset - selection.start.offset,
|
||||||
selection.start.offset,
|
attributes,
|
||||||
textNode.toRawString().length - selection.start.offset,
|
);
|
||||||
attributes,
|
} else {
|
||||||
);
|
for (var i = 0; i < textNodes.length; i++) {
|
||||||
} else if (i == textNodes.length - 1 && textNode == nodes.last) {
|
final textNode = textNodes[i];
|
||||||
builder.formatText(
|
if (i == 0 && textNode == nodes.first) {
|
||||||
textNode,
|
builder.formatText(
|
||||||
0,
|
textNode,
|
||||||
selection.end.offset,
|
selection.start.offset,
|
||||||
attributes,
|
textNode.toRawString().length - selection.start.offset,
|
||||||
);
|
attributes,
|
||||||
} else {
|
);
|
||||||
builder.formatText(
|
} else if (i == textNodes.length - 1 && textNode == nodes.last) {
|
||||||
textNode,
|
builder.formatText(
|
||||||
0,
|
textNode,
|
||||||
textNode.toRawString().length,
|
0,
|
||||||
attributes,
|
selection.end.offset,
|
||||||
);
|
attributes,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
builder.formatText(
|
||||||
|
textNode,
|
||||||
|
0,
|
||||||
|
textNode.toRawString().length,
|
||||||
|
attributes,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ NodeWidgetBuilders defaultBuilders = {
|
|||||||
'text': RichTextNodeWidgetBuilder(),
|
'text': RichTextNodeWidgetBuilder(),
|
||||||
'text/checkbox': CheckboxNodeWidgetBuilder(),
|
'text/checkbox': CheckboxNodeWidgetBuilder(),
|
||||||
'text/heading': HeadingTextNodeWidgetBuilder(),
|
'text/heading': HeadingTextNodeWidgetBuilder(),
|
||||||
'text/bullet-list': BulletedListTextNodeWidgetBuilder(),
|
'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
|
||||||
'text/number-list': NumberListTextNodeWidgetBuilder(),
|
'text/number-list': NumberListTextNodeWidgetBuilder(),
|
||||||
'text/quote': QuotedTextNodeWidgetBuilder(),
|
'text/quote': QuotedTextNodeWidgetBuilder(),
|
||||||
};
|
};
|
||||||
|
@ -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/node.dart';
|
||||||
import 'package:flowy_editor/document/position.dart';
|
import 'package:flowy_editor/document/position.dart';
|
||||||
import 'package:flowy_editor/document/selection.dart';
|
import 'package:flowy_editor/document/selection.dart';
|
||||||
import 'package:flowy_editor/operation/transaction_builder.dart';
|
import 'package:flowy_editor/document/text_delta.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:flowy_editor/extensions/node_extensions.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flowy_editor/extensions/path_extensions.dart';
|
||||||
import 'package:flutter/services.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) {
|
FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
|
||||||
if (event.logicalKey != LogicalKeyboardKey.enter) {
|
if (event.logicalKey != LogicalKeyboardKey.enter) {
|
||||||
@ -23,20 +28,63 @@ FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final textNode = nodes.first as TextNode;
|
final textNode = nodes.first as TextNode;
|
||||||
|
|
||||||
if (textNode.selectable!.end() == selection.end) {
|
if (textNode.selectable!.end() == selection.end) {
|
||||||
TransactionBuilder(editorState)
|
if (textNode.subtype != null && textNode.delta.length == 0) {
|
||||||
..insertNode(
|
TransactionBuilder(editorState)
|
||||||
textNode.path.next,
|
..deleteNode(textNode)
|
||||||
TextNode.empty(),
|
..insertNode(
|
||||||
)
|
textNode.path,
|
||||||
..commit();
|
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;
|
return KeyEventResult.handled;
|
||||||
} else if (textNode.selectable!.start() == selection.start) {
|
} else if (textNode.selectable!.start() == selection.start) {
|
||||||
TransactionBuilder(editorState)
|
TransactionBuilder(editorState)
|
||||||
..insertNode(
|
..insertNode(
|
||||||
textNode.path,
|
textNode.path,
|
||||||
TextNode.empty(),
|
textNode.copyWith(
|
||||||
|
children: LinkedList(),
|
||||||
|
delta: Delta([TextInsert('')]),
|
||||||
|
attributes: {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..afterSelection = Selection.collapsed(
|
||||||
|
Position(
|
||||||
|
path: textNode.path.next,
|
||||||
|
offset: 0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
..commit();
|
..commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:flowy_editor/document/node.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/default_text_operations/format_rich_text_style.dart';
|
||||||
import 'package:flowy_editor/service/keyboard_service.dart';
|
import 'package:flowy_editor/service/keyboard_service.dart';
|
||||||
|
|
||||||
@ -23,9 +21,7 @@ FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
|
|||||||
// bold
|
// bold
|
||||||
case 'B':
|
case 'B':
|
||||||
case 'b':
|
case 'b':
|
||||||
formatRichTextStyle(editorState, {
|
formatBold(editorState);
|
||||||
StyleKey.bold: !textNodes.allSatisfyBoldInSelection(selection),
|
|
||||||
});
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
@ -233,6 +233,9 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
clearSelection();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -455,7 +458,7 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
..forEach((overlay) => overlay.remove())
|
..forEach((overlay) => overlay.remove())
|
||||||
..clear();
|
..clear();
|
||||||
// clear toolbar
|
// clear toolbar
|
||||||
editorState.service.toolbarService.hide();
|
editorState.service.toolbarService?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateSelection(Selection selection) {
|
void _updateSelection(Selection selection) {
|
||||||
@ -526,7 +529,7 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
|
|
||||||
if (topmostRect != null && layerLink != null) {
|
if (topmostRect != null && layerLink != null) {
|
||||||
editorState.service.toolbarService
|
editorState.service.toolbarService
|
||||||
.showInOffset(topmostRect.topLeft, layerLink);
|
?.showInOffset(topmostRect.topLeft, layerLink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,9 +23,11 @@ class FlowyService {
|
|||||||
|
|
||||||
// toolbar service
|
// toolbar service
|
||||||
final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
|
final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
|
||||||
ToolbarService get toolbarService {
|
ToolbarService? get toolbarService {
|
||||||
assert(toolbarServiceKey.currentState != null &&
|
if (toolbarServiceKey.currentState != null &&
|
||||||
toolbarServiceKey.currentState is ToolbarService);
|
toolbarServiceKey.currentState is ToolbarService) {
|
||||||
return toolbarServiceKey.currentState! as ToolbarService;
|
return toolbarServiceKey.currentState! as ToolbarService;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ class _FlowyToolbarState extends State<FlowyToolbar> with ToolbarService {
|
|||||||
editorState: widget.editorState,
|
editorState: widget.editorState,
|
||||||
layerLink: layerLink,
|
layerLink: layerLink,
|
||||||
offset: offset.translate(0, -37.0),
|
offset: offset.translate(0, -37.0),
|
||||||
handlers: const [],
|
handlers: const {},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Overlay.of(context)?.insert(_toolbarOverlay!);
|
Overlay.of(context)?.insert(_toolbarOverlay!);
|
||||||
|
@ -8,6 +8,7 @@ import 'package:flowy_editor/editor_state.dart';
|
|||||||
import 'package:flowy_editor/document/state_tree.dart';
|
import 'package:flowy_editor/document/state_tree.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
group('transform path', () {
|
group('transform path', () {
|
||||||
test('transform path changed', () {
|
test('transform path changed', () {
|
||||||
expect(transformPath([0, 1], [0, 1]), [0, 2]);
|
expect(transformPath([0, 1], [0, 1]), [0, 2]);
|
||||||
@ -87,7 +88,7 @@ void main() {
|
|||||||
"path": [0],
|
"path": [0],
|
||||||
"nodes": [item1.toJson()],
|
"nodes": [item1.toJson()],
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test("delete", () {
|
test("delete", () {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user