Rich text editor for Flutter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

864 lines
25 KiB

import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_quill/models/documents/attribute.dart';
import 'package:flutter_quill/models/documents/nodes/container.dart'
as container;
import 'package:flutter_quill/models/documents/nodes/leaf.dart' as leaf;
import 'package:flutter_quill/models/documents/nodes/leaf.dart';
import 'package:flutter_quill/models/documents/nodes/line.dart';
import 'package:flutter_quill/models/documents/nodes/node.dart';
import 'package:flutter_quill/models/documents/style.dart';
import 'package:flutter_quill/widgets/cursor.dart';
import 'package:flutter_quill/widgets/proxy.dart';
import 'package:flutter_quill/widgets/text_selection.dart';
import 'package:tuple/tuple.dart';
import 'box.dart';
import 'default_styles.dart';
import 'delegate.dart';
class TextLine extends StatelessWidget {
final Line line;
final TextDirection textDirection;
final EmbedBuilder embedBuilder;
const TextLine({Key key, this.line, this.textDirection, this.embedBuilder})
: assert(line != null),
assert(embedBuilder != null),
super(key: key);
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
if (line.hasEmbed) {
Embed embed = line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed));
}
TextSpan textSpan = _buildTextSpan(context);
StrutStyle strutStyle =
StrutStyle.fromTextStyle(textSpan.style, forceStrutHeight: true);
final textAlign = _getTextAlign();
RichText child = RichText(
text: _buildTextSpan(context),
textDirection: textDirection,
strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context),
);
return RichTextProxy(
child,
textSpan.style,
textAlign,
textDirection,
1.0,
Localizations.localeOf(context, nullOk: true),
strutStyle,
TextWidthBasis.parent,
null);
}
TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) {
return TextAlign.left;
} else if (alignment == Attribute.centerAlignment) {
return TextAlign.center;
} else if (alignment == Attribute.rightAlignment) {
return TextAlign.right;
} else if (alignment == Attribute.justifyAlignment) {
return TextAlign.justify;
}
return TextAlign.start;
}
TextSpan _buildTextSpan(BuildContext context) {
DefaultStyles defaultStyles = DefaultStyles.getInstance(context);
List<TextSpan> children = line.children
.map((node) => _getTextSpanFromNode(defaultStyles, node))
.toList(growable: false);
TextStyle textStyle = TextStyle();
Attribute header = line.style.attributes[Attribute.header.key];
Map<Attribute, TextStyle> m = {
Attribute.h1: defaultStyles.h1.style,
Attribute.h2: defaultStyles.h2.style,
Attribute.h3: defaultStyles.h3.style,
};
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph.style);
Attribute block = line.style.getBlockExceptHeader();
TextStyle toMerge;
if (block == Attribute.blockQuote) {
toMerge = defaultStyles.quote.style;
} else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code.style;
} else if (block != null) {
toMerge = defaultStyles.lists.style;
}
textStyle = textStyle.merge(toMerge);
return TextSpan(children: children, style: textStyle);
}
Color _hexStringToColor(String s) {
if (s.startsWith('rgba')) {
s = s.substring(5); // trim left 'rgba('
s = s.substring(0, s.length - 1); // trim right ')'
final arr = s.split(',').map((e) => e.trim()).toList();
return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]),
int.parse(arr[2]), double.parse(arr[3]));
}
if (s == 'transparent') {
return Colors.transparent;
}
String hex = s.replaceFirst('#', '');
hex = hex.length == 6 ? 'ff' + hex : hex;
int val = int.parse(hex, radix: 16);
return Color(val);
}
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) {
leaf.Text textNode = node as leaf.Text;
Style style = textNode.style;
TextStyle res = TextStyle();
Map<String, TextStyle> m = {
Attribute.bold.key: defaultStyles.bold,
Attribute.italic.key: defaultStyles.italic,
Attribute.link.key: defaultStyles.link,
Attribute.underline.key: defaultStyles.underline,
Attribute.strikeThrough.key: defaultStyles.strikeThrough,
};
m.forEach((k, s) {
if (style.values.any((v) => v.key == k)) {
res = _merge(res, s);
}
});
Attribute color = textNode.style.attributes[Attribute.color.key];
if (color != null && color.value != null) {
final textColor = _hexStringToColor(color.value);
res = res.merge(new TextStyle(color: textColor));
}
Attribute background = textNode.style.attributes[Attribute.background.key];
if (background != null && background.value != null) {
final backgroundColor = _hexStringToColor(background.value);
res = res.merge(new TextStyle(backgroundColor: backgroundColor));
}
return TextSpan(text: textNode.value, style: res);
}
TextStyle _merge(TextStyle a, TextStyle b) {
final decorations = <TextDecoration>[];
if (a.decoration != null) {
decorations.add(a.decoration);
}
if (b.decoration != null) {
decorations.add(b.decoration);
}
return a.merge(b).apply(decoration: TextDecoration.combine(decorations));
}
}
class EditableTextLine extends RenderObjectWidget {
final Line line;
final Widget leading;
final Widget body;
final double indentWidth;
final Tuple2 verticalSpacing;
final TextDirection textDirection;
final TextSelection textSelection;
final Color color;
final bool enableInteractiveSelection;
final bool hasFocus;
final double devicePixelRatio;
final CursorCont cursorCont;
EditableTextLine(
this.line,
this.leading,
this.body,
this.indentWidth,
this.verticalSpacing,
this.textDirection,
this.textSelection,
this.color,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.cursorCont)
: assert(line != null),
assert(indentWidth != null),
assert(textSelection != null),
assert(color != null),
assert(enableInteractiveSelection != null),
assert(hasFocus != null),
assert(cursorCont != null);
@override
RenderObjectElement createElement() {
return _TextLineElement(this);
}
@override
RenderObject createRenderObject(BuildContext context) {
return RenderEditableTextLine(
line,
textDirection,
textSelection,
enableInteractiveSelection,
hasFocus,
devicePixelRatio,
_getPadding(),
this.color,
cursorCont);
}
@override
updateRenderObject(
BuildContext context, covariant RenderEditableTextLine renderObject) {
renderObject.setLine(line);
renderObject.setPadding(_getPadding());
renderObject.setTextDirection(textDirection);
renderObject.setTextSelection(textSelection);
renderObject.setColor(color);
renderObject.setEnableInteractiveSelection(enableInteractiveSelection);
renderObject.hasFocus = hasFocus;
renderObject.setDevicePixelRatio(devicePixelRatio);
renderObject.setCursorCont(cursorCont);
}
EdgeInsetsGeometry _getPadding() {
return EdgeInsetsDirectional.only(
start: indentWidth,
top: verticalSpacing.item1,
bottom: verticalSpacing.item2);
}
}
enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox {
RenderBox _leading;
RenderContentProxyBox _body;
Line line;
TextDirection textDirection;
TextSelection textSelection;
Color color;
bool enableInteractiveSelection;
bool hasFocus = false;
double devicePixelRatio;
EdgeInsetsGeometry padding;
CursorCont cursorCont;
EdgeInsets _resolvedPadding;
bool _containsCursor;
List<TextBox> _selectedRects;
Rect _caretPrototype;
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{};
RenderEditableTextLine(
this.line,
this.textDirection,
this.textSelection,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.padding,
this.color,
this.cursorCont)
: assert(line != null),
assert(padding != null),
assert(padding.isNonNegative),
assert(devicePixelRatio != null),
assert(hasFocus != null),
assert(color != null),
assert(cursorCont != null);
Iterable<RenderBox> get _children sync* {
if (_leading != null) {
yield _leading;
}
if (_body != null) {
yield _body;
}
}
setCursorCont(CursorCont c) {
assert(c != null);
if (cursorCont == c) {
return;
}
cursorCont = c;
markNeedsLayout();
}
setDevicePixelRatio(double d) {
if (devicePixelRatio == d) {
return;
}
devicePixelRatio = d;
markNeedsLayout();
}
setEnableInteractiveSelection(bool val) {
if (enableInteractiveSelection == val) {
return;
}
markNeedsLayout();
markNeedsSemanticsUpdate();
}
setColor(Color c) {
if (color == c) {
return;
}
color = c;
if (containsTextSelection()) {
markNeedsPaint();
}
}
setTextSelection(TextSelection t) {
if (textSelection == t) {
return;
}
bool containsSelection = containsTextSelection();
if (attached && containsCursor()) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint);
}
textSelection = t;
_selectedRects = null;
_containsCursor = null;
if (attached && containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint);
}
if (containsSelection || containsTextSelection()) {
markNeedsPaint();
}
}
setTextDirection(TextDirection t) {
if (textDirection == t) {
return;
}
textDirection = t;
_resolvedPadding = null;
markNeedsLayout();
}
setLine(Line l) {
assert(l != null);
if (line == l) {
return;
}
line = l;
_containsCursor = null;
markNeedsLayout();
}
setPadding(EdgeInsetsGeometry p) {
assert(p != null);
assert(p.isNonNegative);
if (padding == p) {
return;
}
padding = p;
_resolvedPadding = null;
markNeedsLayout();
}
setLeading(RenderBox l) {
_leading = _updateChild(_leading, l, TextLineSlot.LEADING);
}
setBody(RenderContentProxyBox b) {
_body = _updateChild(_body, b, TextLineSlot.BODY);
}
bool containsTextSelection() {
return line.getDocumentOffset() <= textSelection.end &&
textSelection.start <= line.getDocumentOffset() + line.length - 1;
}
bool containsCursor() {
return _containsCursor ??= textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset);
}
RenderBox _updateChild(RenderBox old, RenderBox newChild, TextLineSlot slot) {
if (old != null) {
dropChild(old);
children.remove(slot);
}
if (newChild != null) {
children[slot] = newChild;
adoptChild(newChild);
}
return newChild;
}
List<TextBox> _getBoxes(TextSelection textSelection) {
BoxParentData parentData = _body.parentData as BoxParentData;
return _body.getBoxesForSelection(textSelection).map((box) {
return TextBox.fromLTRBD(
box.left + parentData.offset.dx,
box.top + parentData.offset.dy,
box.right + parentData.offset.dx,
box.bottom + parentData.offset.dy,
box.direction,
);
}).toList(growable: false);
}
_resolvePadding() {
if (_resolvedPadding != null) {
return;
}
_resolvedPadding = padding.resolve(textDirection);
assert(_resolvedPadding.isNonNegative);
}
@override
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) {
return _getEndpointForSelection(textSelection, true);
}
@override
TextSelectionPoint getExtentEndpointForSelection(
TextSelection textSelection) {
return _getEndpointForSelection(textSelection, false);
}
TextSelectionPoint _getEndpointForSelection(
TextSelection textSelection, bool first) {
if (textSelection.isCollapsed) {
return TextSelectionPoint(
Offset(0.0, preferredLineHeight(textSelection.extent)) +
getOffsetForCaret(textSelection.extent),
null);
}
List<TextBox> boxes = _getBoxes(textSelection);
assert(boxes.isNotEmpty);
TextBox targetBox = first ? boxes.first : boxes.last;
return TextSelectionPoint(
Offset(targetBox.start, targetBox.bottom), targetBox.direction);
}
@override
TextRange getLineBoundary(TextPosition position) {
double lineDy = getOffsetForCaret(position)
.translate(0.0, 0.5 * preferredLineHeight(position))
.dy;
List<TextBox> lineBoxes =
_getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1))
.where((element) => element.top < lineDy && element.bottom > lineDy)
.toList(growable: false);
return TextRange(
start:
getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset,
end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset);
}
@override
Offset getOffsetForCaret(TextPosition position) {
return _body.getOffsetForCaret(position, _caretPrototype) +
(_body.parentData as BoxParentData).offset;
}
@override
TextPosition getPositionAbove(TextPosition position) {
return _getPosition(position, -0.5);
}
@override
TextPosition getPositionBelow(TextPosition position) {
return _getPosition(position, 1.5);
}
TextPosition _getPosition(TextPosition textPosition, double dyScale) {
assert(textPosition.offset < line.length);
Offset offset = getOffsetForCaret(textPosition)
.translate(0, dyScale * preferredLineHeight(textPosition));
if (_body.size
.contains(offset - (_body.parentData as BoxParentData).offset)) {
return getPositionForOffset(offset);
}
return null;
}
@override
TextPosition getPositionForOffset(Offset offset) {
return _body.getPositionForOffset(
offset - (_body.parentData as BoxParentData).offset);
}
@override
TextRange getWordBoundary(TextPosition position) {
return _body.getWordBoundary(position);
}
@override
double preferredLineHeight(TextPosition position) {
return _body.getPreferredLineHeight();
}
@override
container.Container getContainer() {
return line;
}
double get cursorWidth => cursorCont.style.width;
double get cursorHeight =>
cursorCont.style.height ?? preferredLineHeight(TextPosition(offset: 0));
_computeCaretPrototype() {
assert(defaultTargetPlatform != null);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
_caretPrototype =
Rect.fromLTWH(0.0, 0.0, cursorWidth, cursorHeight + 2);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_caretPrototype =
Rect.fromLTWH(0.0, 2.0, cursorWidth, cursorHeight - 4.0);
break;
default:
throw ('Invalid platform');
}
}
@override
attach(covariant PipelineOwner owner) {
super.attach(owner);
for (final child in _children) {
child.attach(owner);
}
if (containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.cursorColor.addListener(markNeedsPaint);
}
}
@override
detach() {
super.detach();
for (RenderBox child in _children) {
child.detach();
}
if (containsCursor()) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.cursorColor.removeListener(markNeedsPaint);
}
}
@override
redepthChildren() {
_children.forEach(redepthChild);
}
@override
visitChildren(RenderObjectVisitor visitor) {
_children.forEach(visitor);
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
var value = <DiagnosticsNode>[];
void add(RenderBox child, String name) {
if (child != null) {
value.add(child.toDiagnosticsNode(name: name));
}
}
add(_leading, 'leading');
add(_body, 'body');
return value;
}
@override
bool get sizedByParent => false;
@override
double computeMinIntrinsicWidth(double height) {
_resolvePadding();
double horizontalPadding = _resolvedPadding.left + _resolvedPadding.right;
double verticalPadding = _resolvedPadding.top + _resolvedPadding.bottom;
int leadingWidth = _leading == null
? 0
: _leading.getMinIntrinsicWidth(height - verticalPadding);
int bodyWidth = _body == null
? 0
: _body.getMinIntrinsicWidth(math.max(0.0, height - verticalPadding));
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
_resolvePadding();
double horizontalPadding = _resolvedPadding.left + _resolvedPadding.right;
double verticalPadding = _resolvedPadding.top + _resolvedPadding.bottom;
int leadingWidth = _leading == null
? 0
: _leading.getMaxIntrinsicWidth(height - verticalPadding);
int bodyWidth = _body == null
? 0
: _body.getMaxIntrinsicWidth(math.max(0.0, height - verticalPadding));
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
_resolvePadding();
double horizontalPadding = _resolvedPadding.left + _resolvedPadding.right;
double verticalPadding = _resolvedPadding.top + _resolvedPadding.bottom;
if (_body != null) {
return _body
.getMinIntrinsicHeight(math.max(0.0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeMaxIntrinsicHeight(double width) {
_resolvePadding();
double horizontalPadding = _resolvedPadding.left + _resolvedPadding.right;
double verticalPadding = _resolvedPadding.top + _resolvedPadding.bottom;
if (_body != null) {
return _body
.getMaxIntrinsicHeight(math.max(0.0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) {
_resolvePadding();
return _body.getDistanceToActualBaseline(baseline) + _resolvedPadding.top;
}
@override
void performLayout() {
final constraints = this.constraints;
_selectedRects = null;
_resolvePadding();
assert(_resolvedPadding != null);
if (_body == null && _leading == null) {
size = constraints.constrain(Size(
_resolvedPadding.left + _resolvedPadding.right,
_resolvedPadding.top + _resolvedPadding.bottom,
));
return;
}
final innerConstraints = constraints.deflate(_resolvedPadding);
final indentWidth = textDirection == TextDirection.ltr
? _resolvedPadding.left
: _resolvedPadding.right;
_body.layout(innerConstraints, parentUsesSize: true);
final bodyParentData = _body.parentData as BoxParentData;
bodyParentData.offset = Offset(_resolvedPadding.left, _resolvedPadding.top);
if (_leading != null) {
final leadingConstraints = innerConstraints.copyWith(
minWidth: indentWidth,
maxWidth: indentWidth,
maxHeight: _body.size.height);
_leading.layout(leadingConstraints, parentUsesSize: true);
final parentData = _leading.parentData as BoxParentData;
parentData.offset = Offset(0.0, _resolvedPadding.top);
}
size = constraints.constrain(Size(
_resolvedPadding.left + _body.size.width + _resolvedPadding.right,
_resolvedPadding.top + _body.size.height + _resolvedPadding.bottom,
));
_computeCaretPrototype();
}
CursorPainter get _cursorPainter => CursorPainter(
_body,
cursorCont.style,
_caretPrototype,
cursorCont.cursorColor.value,
devicePixelRatio,
);
@override
paint(PaintingContext context, Offset offset) {
if (_leading != null) {
final parentData = _leading.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset;
context.paintChild(_leading, effectiveOffset);
}
if (_body != null) {
final parentData = _body.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset;
if ((enableInteractiveSelection ?? true) &&
line.getDocumentOffset() <= textSelection.end &&
textSelection.start <= line.getDocumentOffset() + line.length - 1) {
final local = localSelection(line, textSelection, false);
_selectedRects ??= _body.getBoxesForSelection(
local,
);
_paintSelection(context, effectiveOffset);
}
if (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
!cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
}
context.paintChild(_body, effectiveOffset);
if (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
}
}
}
_paintSelection(PaintingContext context, Offset effectiveOffset) {
assert(_selectedRects != null);
final paint = Paint()..color = color;
for (final box in _selectedRects) {
context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint);
}
}
_paintCursor(PaintingContext context, Offset effectiveOffset) {
final position = TextPosition(
offset: textSelection.extentOffset - line.getDocumentOffset(),
affinity: textSelection.base.affinity,
);
_cursorPainter.paint(context.canvas, effectiveOffset, position);
}
}
class _TextLineElement extends RenderObjectElement {
_TextLineElement(EditableTextLine line) : super(line);
final Map<TextLineSlot, Element> _slotToChildren = <TextLineSlot, Element>{};
@override
EditableTextLine get widget => super.widget as EditableTextLine;
@override
RenderEditableTextLine get renderObject =>
super.renderObject as RenderEditableTextLine;
@override
visitChildren(ElementVisitor visitor) {
_slotToChildren.values.forEach(visitor);
}
@override
forgetChild(Element child) {
assert(_slotToChildren.containsValue(child));
assert(child.slot is TextLineSlot);
assert(_slotToChildren.containsKey(child.slot));
_slotToChildren.remove(child.slot);
super.forgetChild(child);
}
@override
mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_mountChild(widget.leading, TextLineSlot.LEADING);
_mountChild(widget.body, TextLineSlot.BODY);
}
@override
update(EditableTextLine newWidget) {
super.update(newWidget);
assert(widget == newWidget);
_updateChild(widget.leading, TextLineSlot.LEADING);
_updateChild(widget.body, TextLineSlot.BODY);
}
@override
insertRenderObjectChild(RenderObject child, TextLineSlot slot) {
assert(child is RenderBox);
_updateRenderObject(child, slot);
assert(renderObject.children.keys.contains(slot));
}
@override
removeRenderObjectChild(RenderObject child, TextLineSlot slot) {
assert(child is RenderBox);
assert(renderObject.children[slot] == child);
_updateRenderObject(null, slot);
assert(!renderObject.children.keys.contains(slot));
}
@override
moveRenderObjectChild(RenderObject child, dynamic oldSlot, dynamic newSlot) {
throw UnimplementedError();
}
_mountChild(Widget widget, TextLineSlot slot) {
Element oldChild = _slotToChildren[slot];
Element newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
_updateRenderObject(RenderObject child, TextLineSlot slot) {
switch (slot) {
case TextLineSlot.LEADING:
renderObject.setLeading(child as RenderBox);
break;
case TextLineSlot.BODY:
renderObject.setBody(child as RenderBox);
break;
default:
throw UnimplementedError();
}
}
_updateChild(Widget widget, TextLineSlot slot) {
Element oldChild = _slotToChildren[slot];
Element newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
}