Merge pull request #801 from LucasXu0/feat/text_style

Feat/text style
This commit is contained in:
Nathan.fooo 2022-08-09 19:53:31 +08:00 committed by GitHub
commit 7f249ebae2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 271 additions and 154 deletions

View File

@ -8,15 +8,24 @@ class FlowySvg extends StatelessWidget {
this.size = const Size(20, 20),
this.color,
this.number,
this.padding,
}) : super(key: key);
final String? name;
final Size size;
final Color? color;
final int? number;
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.all(0),
child: _buildSvg(),
);
}
Widget _buildSvg() {
if (name != null) {
return SizedBox.fromSize(
size: size,

View File

@ -1,4 +1,5 @@
import 'dart:collection';
import 'dart:math';
import 'package:flowy_editor/document/attributes.dart';
import 'package:flowy_editor/document/node.dart';
@ -105,7 +106,21 @@ class TransactionBuilder {
insertText(TextNode node, int index, String content,
[Attributes? attributes]) {
textEdit(node, () => Delta().retain(index).insert(content, attributes));
var newAttributes = attributes;
if (index != 0 && attributes == null) {
newAttributes = node.delta
.slice(max(index - 1, 0), index)
.operations
.first
.attributes;
}
textEdit(
node,
() => Delta().retain(index).insert(
content,
newAttributes,
),
);
afterSelection = Selection.collapsed(
Position(path: node.path, offset: index + content.length));
}
@ -121,10 +136,18 @@ class TransactionBuilder {
Selection.collapsed(Position(path: node.path, offset: index));
}
replaceText(TextNode node, int index, int length, String content) {
replaceText(TextNode node, int index, int length, String content,
[Attributes? attributes]) {
var newAttributes = attributes;
if (attributes == null) {
final ops = node.delta.slice(index, index + length).operations;
if (ops.isNotEmpty) {
newAttributes = ops.first.attributes;
}
}
textEdit(
node,
() => Delta().retain(index).delete(length).insert(content),
() => Delta().retain(index).delete(length).insert(content, newAttributes),
);
afterSelection = Selection.collapsed(
Position(

View File

@ -43,38 +43,45 @@ class BulletedListTextNodeWidget extends StatefulWidget {
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
with Selectable, DefaultSelectable {
@override
final iconKey = GlobalKey();
final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text');
final leftPadding = 20.0;
final _iconSize = 20.0;
final _iconRightPadding = 5.0;
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
return Offset(leftPadding, 0);
}
@override
Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox(
width: maxTextNodeWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
size: Size.square(leftPadding),
name: 'point',
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'List',
textNode: widget.textNode,
editorState: widget.editorState,
width: defaultMaxTextNodeWidth,
child: Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
key: iconKey,
size: Size.square(_iconSize),
padding:
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
name: 'point',
),
),
],
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'List',
textNode: widget.textNode,
editorState: widget.editorState,
),
),
],
),
),
);
}

View File

@ -41,19 +41,17 @@ class CheckboxNodeWidget extends StatefulWidget {
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with Selectable, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
@override
final iconKey = GlobalKey();
final leftPadding = 20.0;
final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
final _iconSize = 20.0;
final _iconRightPadding = 5.0;
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
return Offset(leftPadding, 0);
}
@override
Widget build(BuildContext context) {
if (widget.textNode.children.isEmpty) {
@ -65,37 +63,44 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
Widget _buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check;
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox(
width: maxTextNodeWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: FlowySvg(
size: Size.square(leftPadding),
name: check ? 'check' : 'uncheck',
),
onTap: () {
debugPrint('[Checkbox] onTap...');
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
StyleKey.checkbox: !check,
})
..commit();
},
width: defaultMaxTextNodeWidth,
child: Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: FlowySvg(
key: iconKey,
size: Size.square(_iconSize),
padding: EdgeInsets.only(
top: topPadding, right: _iconRightPadding),
name: check ? 'check' : 'uncheck',
),
onTap: () {
debugPrint('[Checkbox] onTap...');
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
StyleKey.checkbox: !check,
})
..commit();
},
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'To-do',
textNode: widget.textNode,
textSpanDecorator: _textSpanDecorator,
placeholderTextSpanDecorator: _textSpanDecorator,
editorState: widget.editorState,
),
),
],
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'To-do',
textNode: widget.textNode,
textSpanDecorator: _textSpanDecorator,
editorState: widget.editorState,
),
),
],
),
);
));
}
Widget _buildWithChildren(BuildContext context) {

View File

@ -6,7 +6,17 @@ import 'package:flutter/material.dart';
mixin DefaultSelectable {
Selectable get forward;
Offset get baseOffset;
GlobalKey? get iconKey;
Offset get baseOffset {
if (iconKey != null) {
final renderBox = iconKey!.currentContext?.findRenderObject();
if (renderBox is RenderBox) {
return Offset(renderBox.size.width, 0);
}
}
return Offset.zero;
}
Position getPositionInOffset(Offset start) =>
forward.getPositionInOffset(start);

View File

@ -41,6 +41,8 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
final _textKey = GlobalKey();
final _placeholderTextKey = GlobalKey();
final lineHeight = 1.5;
RenderParagraph get _renderParagraph =>
_textKey.currentContext?.findRenderObject() as RenderParagraph;
@ -145,6 +147,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
? Colors.transparent
: Colors.grey,
fontSize: baseFontSize,
height: lineHeight,
),
),
],
@ -200,6 +203,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
.map((insert) => RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
height: lineHeight,
).toTextSpan())
.toList(growable: false),
);

View File

@ -41,9 +41,11 @@ class HeadingTextNodeWidget extends StatefulWidget {
class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
with Selectable, DefaultSelectable {
@override
GlobalKey? get iconKey => null;
final _richTextKey = GlobalKey(debugLabel: 'heading_text');
final topPadding = 5.0;
final bottomPadding = 2.0;
final _topPadding = 5.0;
@override
Selectable<StatefulWidget> get forward =>
@ -51,18 +53,18 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
@override
Offset get baseOffset {
return Offset(0, topPadding);
return Offset(0, _topPadding);
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: maxTextNodeWidth,
child: Padding(
padding: EdgeInsets.only(
top: topPadding,
bottom: bottomPadding,
),
return Padding(
padding: EdgeInsets.only(
top: _topPadding,
bottom: defaultLinePadding,
),
child: SizedBox(
width: defaultMaxTextNodeWidth,
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'Heading',

View File

@ -43,39 +43,44 @@ class NumberListTextNodeWidget extends StatefulWidget {
class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
with Selectable, DefaultSelectable {
@override
final iconKey = GlobalKey();
final _richTextKey = GlobalKey(debugLabel: 'number_list_text');
final leftPadding = 20.0;
final _iconSize = 20.0;
final _iconRightPadding = 5.0;
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
return Offset(leftPadding, 0);
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: maxTextNodeWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
size: Size.square(leftPadding),
number: widget.textNode.attributes.number,
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding),
child: SizedBox(
width: defaultMaxTextNodeWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
key: iconKey,
size: Size.square(_iconSize),
padding:
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
number: widget.textNode.attributes.number,
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'List',
textNode: widget.textNode,
editorState: widget.editorState,
),
),
],
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'List',
textNode: widget.textNode,
editorState: widget.editorState,
),
),
],
),
);
));
}
}

View File

@ -42,48 +42,50 @@ class QuotedTextNodeWidget extends StatefulWidget {
class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
with Selectable, DefaultSelectable {
@override
final iconKey = GlobalKey();
final _richTextKey = GlobalKey(debugLabel: 'quoted_text');
final leftPadding = 20.0;
final _iconSize = 20.0;
final _iconRightPadding = 5.0;
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
return Offset(leftPadding, 0);
}
@override
Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox(
width: maxTextNodeWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
size: Size(
leftPadding,
_quoteHeight,
),
name: 'quote',
width: defaultMaxTextNodeWidth,
child: Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
key: iconKey,
size: Size(_iconSize, _quoteHeight),
padding:
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
name: 'quote',
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'Quote',
textNode: widget.textNode,
editorState: widget.editorState,
),
),
],
),
Expanded(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'Quote',
textNode: widget.textNode,
editorState: widget.editorState,
),
),
],
),
);
));
}
double get _quoteHeight {
final lines =
widget.textNode.toRawString().characters.where((c) => c == '\n').length;
return (lines + 1) * leftPadding;
return (lines + 1) * _iconSize;
}
}

View File

@ -42,26 +42,26 @@ class RichTextNodeWidget extends StatefulWidget {
class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
with Selectable, DefaultSelectable {
@override
GlobalKey? get iconKey => null;
final _richTextKey = GlobalKey(debugLabel: 'rich_text');
final leftPadding = 20.0;
@override
Selectable<StatefulWidget> get forward =>
_richTextKey.currentState as Selectable;
@override
Offset get baseOffset {
return Offset.zero;
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: maxTextNodeWidth,
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
width: defaultMaxTextNodeWidth,
child: Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding),
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
),
),
);
}

View File

@ -1,4 +1,5 @@
import 'package:flowy_editor/document/attributes.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -50,6 +51,7 @@ class StyleKey {
];
static List<String> globalStyleKeys = [
StyleKey.subtype,
StyleKey.heading,
StyleKey.checkbox,
StyleKey.bulletedList,
@ -60,7 +62,8 @@ class StyleKey {
}
// TODO: customize
double maxTextNodeWidth = 780.0;
double defaultMaxTextNodeWidth = 780.0;
double defaultLinePadding = 8.0;
double baseFontSize = 16.0;
// TODO: customize.
Map<String, double> headingToFontSize = {
@ -176,12 +179,33 @@ class RichTextStyle {
RichTextStyle({
required this.attributes,
required this.text,
this.height = 1.5,
});
RichTextStyle.fromTextNode(TextNode textNode)
: this(attributes: textNode.attributes, text: textNode.toRawString());
final Attributes attributes;
final String text;
final double height;
TextSpan toTextSpan() {
TextSpan toTextSpan() => _toTextSpan(height);
double get topPadding {
if (height == 1.0) {
return 0;
}
// TODO: Need to be optimized.
final painter =
TextPainter(text: _toTextSpan(height), textDirection: TextDirection.ltr)
..layout();
final basePainter =
TextPainter(text: _toTextSpan(null), textDirection: TextDirection.ltr)
..layout();
return painter.height - basePainter.height;
}
TextSpan _toTextSpan(double? height) {
return TextSpan(
text: text,
style: TextStyle(
@ -191,6 +215,7 @@ class RichTextStyle {
color: _textColor,
decoration: _textDecoration,
background: _background,
height: height,
),
recognizer: _recognizer,
);

View File

@ -67,16 +67,31 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
// If selection is collapsed and position.start.offset == 0,
// insert a empty text node before.
if (selection.isCollapsed && selection.start.offset == 0) {
final afterSelection = Selection.collapsed(
Position(path: textNode.path.next, offset: 0),
);
TransactionBuilder(editorState)
..insertNode(
textNode.path,
TextNode.empty(),
)
..afterSelection = afterSelection
..commit();
if (textNode.toRawString().isEmpty) {
final afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: 0),
);
TransactionBuilder(editorState)
..updateNode(
textNode,
Attributes.fromIterable(
StyleKey.globalStyleKeys,
value: (_) => null,
))
..afterSelection = afterSelection
..commit();
} else {
final afterSelection = Selection.collapsed(
Position(path: textNode.path.next, offset: 0),
);
TransactionBuilder(editorState)
..insertNode(
textNode.path,
TextNode.empty(),
)
..afterSelection = afterSelection
..commit();
}
return KeyEventResult.handled;
}
@ -85,6 +100,13 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
final needCopyAttributes = StyleKey.globalStyleKeys
.where((key) => key != StyleKey.heading)
.contains(textNode.subtype);
Attributes attributes = {};
if (needCopyAttributes) {
attributes = Attributes.from(textNode.attributes);
if (attributes.check) {
attributes[StyleKey.checkbox] = false;
}
}
final afterSelection = Selection.collapsed(
Position(path: textNode.path.next, offset: 0),
);
@ -92,8 +114,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
..insertNode(
textNode.path.next,
textNode.copyWith(
attributes:
needCopyAttributes ? Attributes.from(textNode.attributes) : {},
attributes: attributes,
delta: textNode.delta.slice(selection.end.offset),
),
)

View File

@ -326,6 +326,9 @@ class _FlowySelectionState extends State<FlowySelection>
return;
}
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
panEndOffset = details.globalPosition;
final dy = editorState.service.scrollService?.dy;
var panStartOffsetWithScrollDyGap = panStartOffset!;
@ -356,9 +359,10 @@ class _FlowySelectionState extends State<FlowySelection>
start: isDownward ? start : end, end: isDownward ? end : start);
debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
editorState.updateCursorSelection(selection);
_scrollUpOrDownIfNeeded(panEndOffset!, isDownward);
}
_scrollUpOrDownIfNeeded(panEndOffset!);
_showDebugLayerIfNeeded();
}
@ -483,7 +487,7 @@ class _FlowySelectionState extends State<FlowySelection>
return NodeIterator(stateTree, startNode, endNode).toList();
}
void _scrollUpOrDownIfNeeded(Offset offset) {
void _scrollUpOrDownIfNeeded(Offset offset, bool isDownward) {
final dy = editorState.service.scrollService?.dy;
if (dy == null) {
assert(false, 'Dy could not be null');
@ -495,10 +499,10 @@ class _FlowySelectionState extends State<FlowySelection>
/// TODO: It is necessary to calculate the relative speed
/// according to the gap and move forward more gently.
final distance = 10.0;
if (offset.dy <= topLimit) {
if (offset.dy <= topLimit && !isDownward) {
// up
editorState.service.scrollService?.scrollTo(dy - distance);
} else if (offset.dy >= bottomLimit) {
} else if (offset.dy >= bottomLimit && isDownward) {
//down
editorState.service.scrollService?.scrollTo(dy + distance);
}