mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
commit
7f249ebae2
@ -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,
|
||||
|
@ -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(
|
||||
|
@ -43,27 +43,33 @@ 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,
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: defaultLinePadding),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowySvg(
|
||||
size: Size.square(leftPadding),
|
||||
key: iconKey,
|
||||
size: Size.square(_iconSize),
|
||||
padding:
|
||||
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
|
||||
name: 'point',
|
||||
),
|
||||
Expanded(
|
||||
@ -76,6 +82,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,14 +63,20 @@ 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,
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: defaultLinePadding),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: FlowySvg(
|
||||
size: Size.square(leftPadding),
|
||||
key: iconKey,
|
||||
size: Size.square(_iconSize),
|
||||
padding: EdgeInsets.only(
|
||||
top: topPadding, right: _iconRightPadding),
|
||||
name: check ? 'check' : 'uncheck',
|
||||
),
|
||||
onTap: () {
|
||||
@ -90,12 +94,13 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
placeholderText: 'To-do',
|
||||
textNode: widget.textNode,
|
||||
textSpanDecorator: _textSpanDecorator,
|
||||
placeholderTextSpanDecorator: _textSpanDecorator,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildWithChildren(BuildContext context) {
|
||||
|
@ -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);
|
||||
|
@ -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),
|
||||
);
|
||||
|
@ -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(
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: topPadding,
|
||||
bottom: bottomPadding,
|
||||
top: _topPadding,
|
||||
bottom: defaultLinePadding,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
placeholderText: 'Heading',
|
||||
|
@ -43,27 +43,32 @@ 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,
|
||||
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(
|
||||
size: Size.square(leftPadding),
|
||||
key: iconKey,
|
||||
size: Size.square(_iconSize),
|
||||
padding:
|
||||
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
|
||||
number: widget.textNode.attributes.number,
|
||||
),
|
||||
Expanded(
|
||||
@ -76,6 +81,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -42,30 +42,32 @@ 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,
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: defaultLinePadding),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowySvg(
|
||||
size: Size(
|
||||
leftPadding,
|
||||
_quoteHeight,
|
||||
),
|
||||
key: iconKey,
|
||||
size: Size(_iconSize, _quoteHeight),
|
||||
padding:
|
||||
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
|
||||
name: 'quote',
|
||||
),
|
||||
Expanded(
|
||||
@ -78,12 +80,12 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
double get _quoteHeight {
|
||||
final lines =
|
||||
widget.textNode.toRawString().characters.where((c) => c == '\n').length;
|
||||
return (lines + 1) * leftPadding;
|
||||
return (lines + 1) * _iconSize;
|
||||
}
|
||||
}
|
||||
|
@ -42,27 +42,27 @@ 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,
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: defaultLinePadding),
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
textNode: widget.textNode,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -67,6 +67,20 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
// If selection is collapsed and position.start.offset == 0,
|
||||
// insert a empty text node before.
|
||||
if (selection.isCollapsed && selection.start.offset == 0) {
|
||||
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),
|
||||
);
|
||||
@ -77,6 +91,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
)
|
||||
..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),
|
||||
),
|
||||
)
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user