feat: add checkbox and heading style

This commit is contained in:
Lucas.Xu 2022-07-28 19:40:13 +08:00
parent 612e3dd50f
commit 734b642fcc
10 changed files with 349 additions and 16 deletions

View File

@ -68,7 +68,7 @@
{ "insert": " your ", "attributes": { "bold": true } }, { "insert": " your ", "attributes": { "bold": true } },
{ "insert": "writing", "attributes": { "underline": true } }, { "insert": "writing", "attributes": { "underline": true } },
{ {
"insert": " howeverv you like.", "insert": " however you like.",
"attributes": { "strikethrough": true } "attributes": { "strikethrough": true }
} }
], ],

View File

@ -17,6 +17,7 @@
} }
], ],
"attributes": { "attributes": {
"subtype": "heading",
"heading": "h1" "heading": "h1"
} }
}, },
@ -28,9 +29,68 @@
} }
], ],
"attributes": { "attributes": {
"subtype": "heading",
"heading": "h2" "heading": "h2"
} }
}, },
{
"type": "text",
"delta": [
{
"insert": "Here are the plugin demos:"
}
],
"attributes": {
"subtype": "heading",
"heading": "h3"
}
},
{
"type": "text",
"delta": [
{
"insert": "Checkbox example ......"
}
],
"attributes": {
"subtype": "checkbox",
"checkbox": false
},
"children": [
{
"type": "text",
"delta": [
{
"insert": "AAA Checkbox example ......\nAAA Checkbox example ......"
}
],
"attributes": {
"subtype": "checkbox",
"checkbox": false
}
},
{
"type": "text",
"delta": [
{
"insert": "BBB Checkbox example ......"
}
],
"attributes": {
"subtype": "checkbox",
"checkbox": true
}
}
]
},
{
"type": "text",
"delta": [
{
"insert": "Raw text example ......"
}
]
},
{ {
"type": "text", "type": "text",
"delta": [ "delta": [
@ -39,6 +99,7 @@
} }
], ],
"attributes": { "attributes": {
"subtype": "heading",
"heading": "h3" "heading": "h3"
} }
}, },
@ -97,6 +158,7 @@
} }
], ],
"attributes": { "attributes": {
"subtype": "heading",
"heading": "h3" "heading": "h3"
} }
}, },

View File

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flowy_editor/render/rich_text/checkbox_text.dart';
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
import 'package:flowy_editor/render/rich_text/heading_text.dart';
import 'package:flowy_editor/service/service.dart'; import 'package:flowy_editor/service/service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -43,6 +45,8 @@ class EditorState {
}) { }) {
// FIXME: abstract render plugins as a service. // FIXME: abstract render plugins as a service.
renderPlugins.register('text', RichTextNodeWidgetBuilder.create); renderPlugins.register('text', RichTextNodeWidgetBuilder.create);
renderPlugins.register('text/checkbox', CheckboxNodeWidgetBuilder.create);
renderPlugins.register('text/heading', HeadingTextNodeWidgetBuilder.create);
undoManager.state = this; undoManager.state = this;
} }

View File

@ -0,0 +1,132 @@
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/infra/flowy_svg.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/default_selectable.dart';
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/render/selection/selectable.dart';
import 'package:flowy_editor/extensions/object_extensions.dart';
import 'package:flutter/material.dart';
class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder {
CheckboxNodeWidgetBuilder.create({
required super.editorState,
required super.node,
required super.key,
}) : super.create();
@override
Widget build(BuildContext context) {
return CheckboxNodeWidget(
key: key,
textNode: node as TextNode,
editorState: editorState,
);
}
}
class CheckboxNodeWidget extends StatefulWidget {
const CheckboxNodeWidget({
Key? key,
required this.textNode,
required this.editorState,
}) : super(key: key);
final TextNode textNode;
final EditorState editorState;
@override
State<CheckboxNodeWidget> createState() => _CheckboxNodeWidgetState();
}
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with Selectable, DefaultSelectable {
final _checkboxKey = GlobalKey(debugLabel: 'checkbox');
final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
final width = _checkboxKey.currentContext
?.findRenderObject()
?.unwrapOrNull<RenderBox>()
?.size
.width;
if (width != null) {
return Offset(width, 0);
}
return Offset.zero;
}
@override
Widget build(BuildContext context) {
if (widget.textNode.children.isEmpty) {
return _buildWithSingle(context);
} else {
return _buildWithChildren(context);
}
}
Widget _buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.checkbox;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: FlowySvg(
key: _checkboxKey,
name: check ? 'check' : 'uncheck',
),
onTap: () {
debugPrint('[Checkbox] onTap...');
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
'checkbox': !check,
})
..commit();
},
),
FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
)
],
);
}
Widget _buildWithChildren(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWithSingle(context),
Row(
children: [
const SizedBox(
width: 20,
),
Column(
children: widget.textNode.children
.map(
(child) => widget.editorState.renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
)
],
)
],
);
}
}

View File

@ -0,0 +1,28 @@
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flowy_editor/render/selection/selectable.dart';
import 'package:flutter/material.dart';
mixin DefaultSelectable {
Selectable get forward;
Offset get baseOffset;
Position getPositionInOffset(Offset start) =>
forward.getPositionInOffset(start);
Rect getCursorRectInPosition(Position position) =>
forward.getCursorRectInPosition(position).shift(baseOffset);
List<Rect> getRectsInSelection(Selection selection) => forward
.getRectsInSelection(selection)
.map((rect) => rect.shift(baseOffset))
.toList(growable: false);
Selection getSelectionInRange(Offset start, Offset end) =>
forward.getSelectionInRange(start, end);
Position start() => forward.start();
Position end() => forward.end();
}

View File

@ -32,11 +32,14 @@ class RichTextNodeWidgetBuilder extends NodeWidgetBuilder {
} }
} }
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
class FlowyRichText extends StatefulWidget { class FlowyRichText extends StatefulWidget {
const FlowyRichText({ const FlowyRichText({
Key? key, Key? key,
this.cursorHeight, this.cursorHeight,
this.cursorWidth = 2.0, this.cursorWidth = 2.0,
this.textSpanDecorator,
required this.textNode, required this.textNode,
required this.editorState, required this.editorState,
}) : super(key: key); }) : super(key: key);
@ -45,6 +48,7 @@ class FlowyRichText extends StatefulWidget {
final double cursorWidth; final double cursorWidth;
final TextNode textNode; final TextNode textNode;
final EditorState editorState; final EditorState editorState;
final FlowyTextSpanDecorator? textSpanDecorator;
@override @override
State<FlowyRichText> createState() => _FlowyRichTextState(); State<FlowyRichText> createState() => _FlowyRichTextState();
@ -70,7 +74,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
} else if (attributes.quote == true) { } else if (attributes.quote == true) {
return _buildQuotedRichText(context); return _buildQuotedRichText(context);
} else if (attributes.heading != null) { } else if (attributes.heading != null) {
return _buildHeadingRichText(context); // return _buildHeadingRichText(context);
} else if (attributes.number != null) { } else if (attributes.number != null) {
return _buildNumberListRichText(context); return _buildNumberListRichText(context);
} }
@ -87,14 +91,13 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
@override @override
Rect getCursorRectInPosition(Position position) { Rect getCursorRectInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset); final textPosition = TextPosition(offset: position.offset);
final baseRect = frontWidgetRect();
final cursorOffset = final cursorOffset =
_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 5.0; // default height
return Rect.fromLTWH( return Rect.fromLTWH(
baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2), cursorOffset.dx - (widget.cursorWidth / 2),
cursorOffset.dy, cursorOffset.dy,
widget.cursorWidth, widget.cursorWidth,
cursorHeight, cursorHeight,
@ -138,11 +141,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
} }
Widget _buildRichText(BuildContext context) { Widget _buildRichText(BuildContext context) {
if (_textNode.children.isEmpty) { return _buildSingleRichText(context);
return _buildSingleRichText(context);
} else {
return _buildRichTextWithChildren(context);
}
} }
Widget _buildRichTextWithChildren(BuildContext context) { Widget _buildRichTextWithChildren(BuildContext context) {
@ -166,10 +165,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
} }
Widget _buildSingleRichText(BuildContext context) { Widget _buildSingleRichText(BuildContext context) {
return SizedBox( return RichText(
width: key: _textKey,
MediaQuery.of(context).size.width - 20, // FIXME: use the const value text: widget.textSpanDecorator != null
child: RichText(key: _textKey, text: _decorateTextSpanWithGlobalStyle), ? widget.textSpanDecorator!(_decorateTextSpanWithGlobalStyle)
: _decorateTextSpanWithGlobalStyle,
); );
} }

View File

@ -0,0 +1,97 @@
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/infra/flowy_svg.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/default_selectable.dart';
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/render/selection/selectable.dart';
import 'package:flowy_editor/extensions/object_extensions.dart';
import 'package:flutter/material.dart';
class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder {
HeadingTextNodeWidgetBuilder.create({
required super.editorState,
required super.node,
required super.key,
}) : super.create();
@override
Widget build(BuildContext context) {
return HeadingTextNodeWidget(
key: key,
textNode: node as TextNode,
editorState: editorState,
);
}
}
class HeadingTextNodeWidget extends StatefulWidget {
const HeadingTextNodeWidget({
Key? key,
required this.textNode,
required this.editorState,
}) : super(key: key);
final TextNode textNode;
final EditorState editorState;
@override
State<HeadingTextNodeWidget> createState() => _HeadingTextNodeWidgetState();
}
// customize
class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
with Selectable, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: 'heading_text');
final topPadding = 5.0;
final bottomPadding = 2.0;
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
return Offset(0, topPadding);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(
height: topPadding,
),
FlowyRichText(
key: _richTextKey,
textSpanDecorator: _textSpanDecorator,
textNode: widget.textNode,
editorState: widget.editorState,
),
SizedBox(
height: bottomPadding,
),
],
);
}
TextSpan _textSpanDecorator(TextSpan textSpan) {
return TextSpan(
children: textSpan.children
?.whereType<TextSpan>()
.map(
(span) => TextSpan(
text: span.text,
style: span.style?.copyWith(
fontSize: widget.textNode.attributes.fontSize,
),
recognizer: span.recognizer,
),
)
.toList(),
);
}
}

View File

@ -25,12 +25,15 @@ class StyleKey {
static String font = 'font'; static String font = 'font';
static String href = 'href'; static String href = 'href';
static String heading = 'heading';
static String quote = 'quote'; static String quote = 'quote';
static String list = 'list'; static String list = 'list';
static String number = 'number'; static String number = 'number';
static String todo = 'todo'; static String todo = 'todo';
static String code = 'code'; static String code = 'code';
static String subtype = 'subtype';
static String checkbox = 'checkbox';
static String heading = 'heading';
} }
double baseFontSize = 16.0; double baseFontSize = 16.0;
@ -100,6 +103,13 @@ extension NodeAttributesExtensions on Attributes {
} }
return false; return false;
} }
bool get checkbox {
if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) {
return this[StyleKey.checkbox];
}
return false;
}
} }
extension DeltaAttributesExtensions on Attributes { extension DeltaAttributesExtensions on Attributes {

View File

@ -23,7 +23,7 @@ class FlowyEditor extends StatefulWidget {
final EditorState editorState; final EditorState editorState;
final List<FlowyKeyEventHandler> keyEventHandlers; final List<FlowyKeyEventHandler> keyEventHandlers;
/// Shortcusts /// shortcuts
final FloatingShortcuts shortcuts; final FloatingShortcuts shortcuts;
@override @override

View File

@ -165,7 +165,7 @@ class _FlowySelectionState extends State<FlowySelection>
(recognizer) { (recognizer) {
recognizer.onTapDown = _onTapDown; recognizer.onTapDown = _onTapDown;
}, },
) ),
}, },
child: widget.child, child: widget.child,
); );