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.
 
 
 
 
 

1302 lines
38 KiB

import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/documents/attribute.dart';
import '../models/documents/nodes/container.dart' as container_node;
import '../models/documents/nodes/embeddable.dart';
import '../models/documents/nodes/leaf.dart';
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart';
import '../models/structs/vertical_spacing.dart';
import '../utils/color.dart';
import '../utils/font.dart';
import '../utils/platform.dart';
import 'box.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart';
import 'text_selection.dart';
class TextLine extends StatefulWidget {
const TextLine({
required this.line,
required this.embedBuilder,
required this.styles,
required this.readOnly,
required this.controller,
required this.onLaunchUrl,
required this.linkActionPicker,
this.textDirection,
this.customStyleBuilder,
Key? key,
}) : super(key: key);
final Line line;
final TextDirection? textDirection;
final EmbedsBuilder embedBuilder;
final DefaultStyles styles;
final bool readOnly;
final QuillController controller;
final CustomStyleBuilder? customStyleBuilder;
final ValueChanged<String>? onLaunchUrl;
final LinkActionPicker linkActionPicker;
@override
State<TextLine> createState() => _TextLineState();
}
class _TextLineState extends State<TextLine> {
bool _metaOrControlPressed = false;
UniqueKey _richTextKey = UniqueKey();
final _linkRecognizers = <Node, GestureRecognizer>{};
QuillPressedKeys? _pressedKeys;
void _pressedKeysChanged() {
final newValue = _pressedKeys!.metaPressed || _pressedKeys!.controlPressed;
if (_metaOrControlPressed != newValue) {
setState(() {
_metaOrControlPressed = newValue;
_richTextKey = UniqueKey();
});
}
}
bool get canLaunchLinks {
// In readOnly mode users can launch links
// by simply tapping (clicking) on them
if (widget.readOnly) return true;
// In editing mode it depends on the platform:
// Desktop platforms (macos, linux, windows):
// only allow Meta(Control)+Click combinations
if (isDesktop()) {
return _metaOrControlPressed;
}
// Mobile platforms (ios, android): always allow but we install a
// long-press handler instead of a tap one. LongPress is followed by a
// context menu with actions.
return true;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_pressedKeys == null) {
_pressedKeys = QuillPressedKeys.of(context);
_pressedKeys!.addListener(_pressedKeysChanged);
} else {
_pressedKeys!.removeListener(_pressedKeysChanged);
_pressedKeys = QuillPressedKeys.of(context);
_pressedKeys!.addListener(_pressedKeysChanged);
}
}
@override
void didUpdateWidget(covariant TextLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.readOnly != widget.readOnly) {
_richTextKey = UniqueKey();
_linkRecognizers
..forEach((key, value) {
value.dispose();
})
..clear();
}
}
@override
void dispose() {
_pressedKeys?.removeListener(_pressedKeysChanged);
_linkRecognizers
..forEach((key, value) => value.dispose())
..clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
if (widget.line.hasEmbed && widget.line.childCount == 1) {
// Single child embeds can be expanded
var embed = widget.line.children.single as Embed;
// Creates correct node for custom embed
if (embed.value.type == BlockEmbed.customType) {
embed = Embed(CustomBlockEmbed.fromJsonString(embed.value.data));
}
final embedBuilder = widget.embedBuilder(embed);
if (embedBuilder.expanded) {
// Creates correct node for custom embed
return EmbedProxy(
embedBuilder.build(
context,
widget.controller,
embed,
widget.readOnly,
false,
),
);
}
}
final textSpan = _getTextSpanForWholeLine(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign();
final child = RichText(
key: _richTextKey,
text: textSpan,
textAlign: textAlign,
textDirection: widget.textDirection,
strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context),
);
return RichTextProxy(
textStyle: textSpan.style!,
textAlign: textAlign,
textDirection: widget.textDirection!,
strutStyle: strutStyle,
locale: Localizations.localeOf(context),
child: child);
}
InlineSpan _getTextSpanForWholeLine(BuildContext context) {
final lineStyle = _getLineStyle(widget.styles);
if (!widget.line.hasEmbed) {
return _buildTextSpan(widget.styles, widget.line.children, lineStyle);
}
// The line could contain more than one Embed & more than one Text
final textSpanChildren = <InlineSpan>[];
var textNodes = LinkedList<Node>();
for (var child in widget.line.children) {
if (child is Embed) {
if (textNodes.isNotEmpty) {
textSpanChildren
.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
textNodes = LinkedList<Node>();
}
// Creates correct node for custom embed
if (child.value.type == BlockEmbed.customType) {
child = Embed(CustomBlockEmbed.fromJsonString(child.value.data));
}
final embedBuilder = widget.embedBuilder(child);
final embedWidget = EmbedProxy(
embedBuilder.build(
context,
widget.controller,
child,
widget.readOnly,
true,
),
);
final embed = embedBuilder.buildWidgetSpan(embedWidget);
textSpanChildren.add(embed);
continue;
}
// here child is Text node and its value is cloned
textNodes.add(child.clone());
}
if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
}
return TextSpan(style: lineStyle, children: textSpanChildren);
}
TextAlign _getTextAlign() {
final alignment = widget.line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) {
return TextAlign.start;
} else if (alignment == Attribute.centerAlignment) {
return TextAlign.center;
} else if (alignment == Attribute.rightAlignment) {
return TextAlign.end;
} else if (alignment == Attribute.justifyAlignment) {
return TextAlign.justify;
}
return TextAlign.start;
}
TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes,
TextStyle lineStyle) {
if (nodes.isEmpty && kIsWeb) {
nodes = LinkedList<Node>()..add(leaf.Text('\u{200B}'));
}
final children = nodes
.map((node) =>
_getTextSpanFromNode(defaultStyles, node, widget.line.style))
.toList(growable: false);
return TextSpan(children: children, style: lineStyle);
}
TextStyle _getLineStyle(DefaultStyles defaultStyles) {
var textStyle = const TextStyle();
if (widget.line.style.containsKey(Attribute.placeholder.key)) {
return defaultStyles.placeHolder!.style;
}
final header = widget.line.style.attributes[Attribute.header.key];
final m = <Attribute, TextStyle>{
Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style,
Attribute.h3: defaultStyles.h3!.style,
};
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style);
// Only retrieve exclusive block format for the line style purpose
Attribute? block;
widget.line.style.getBlocksExceptHeader().forEach((key, value) {
if (Attribute.exclusiveBlockKeys.contains(key)) {
block = value;
}
});
TextStyle? toMerge;
if (block == Attribute.blockQuote) {
toMerge = defaultStyles.quote!.style;
} else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code!.style;
} else if (block == Attribute.list) {
toMerge = defaultStyles.lists!.style;
}
textStyle = textStyle.merge(toMerge);
textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes);
return textStyle;
}
TextStyle _applyCustomAttributes(
TextStyle textStyle, Map<String, Attribute> attributes) {
if (widget.customStyleBuilder == null) {
return textStyle;
}
attributes.keys.forEach((key) {
final attr = attributes[key];
if (attr != null) {
/// Custom Attribute
final customAttr = widget.customStyleBuilder!.call(attr);
textStyle = textStyle.merge(customAttr);
}
});
return textStyle;
}
TextSpan _getTextSpanFromNode(
DefaultStyles defaultStyles, Node node, Style lineStyle) {
final textNode = node as leaf.Text;
final nodeStyle = textNode.style;
final isLink = nodeStyle.containsKey(Attribute.link.key) &&
nodeStyle.attributes[Attribute.link.key]!.value != null;
return TextSpan(
text: textNode.value,
style: _getInlineTextStyle(
textNode, defaultStyles, nodeStyle, lineStyle, isLink),
recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null,
mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null,
);
}
TextStyle _getInlineTextStyle(leaf.Text textNode, DefaultStyles defaultStyles,
Style nodeStyle, Style lineStyle, bool isLink) {
var res = const TextStyle(); // This is inline text style
final color = textNode.style.attributes[Attribute.color.key];
<String, TextStyle?>{
Attribute.bold.key: defaultStyles.bold,
Attribute.italic.key: defaultStyles.italic,
Attribute.small.key: defaultStyles.small,
Attribute.link.key: defaultStyles.link,
Attribute.underline.key: defaultStyles.underline,
Attribute.strikeThrough.key: defaultStyles.strikeThrough,
}.forEach((k, s) {
if (nodeStyle.values.any((v) => v.key == k)) {
if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) {
var textColor = defaultStyles.color;
if (color?.value is String) {
textColor = stringToColor(color?.value, textColor);
}
res = _merge(res.copyWith(decorationColor: textColor),
s!.copyWith(decorationColor: textColor));
} else if (k == Attribute.link.key && !isLink) {
// null value for link should be ignored
// i.e. nodeStyle.attributes[Attribute.link.key]!.value == null
} else {
res = _merge(res, s!);
}
}
});
if (nodeStyle.containsKey(Attribute.inlineCode.key)) {
res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle));
}
final font = textNode.style.attributes[Attribute.font.key];
if (font != null && font.value != null) {
res = res.merge(TextStyle(fontFamily: font.value));
}
final size = textNode.style.attributes[Attribute.size.key];
if (size != null && size.value != null) {
switch (size.value) {
case 'small':
res = res.merge(defaultStyles.sizeSmall);
break;
case 'large':
res = res.merge(defaultStyles.sizeLarge);
break;
case 'huge':
res = res.merge(defaultStyles.sizeHuge);
break;
default:
res = res.merge(TextStyle(fontSize: getFontSize(size.value)));
}
}
if (color != null && color.value != null) {
var textColor = defaultStyles.color;
if (color.value is String) {
textColor = stringToColor(color.value);
}
if (textColor != null) {
res = res.merge(TextStyle(color: textColor));
}
}
final background = textNode.style.attributes[Attribute.background.key];
if (background != null && background.value != null) {
final backgroundColor = stringToColor(background.value);
res = res.merge(TextStyle(backgroundColor: backgroundColor));
}
res = _applyCustomAttributes(res, textNode.style.attributes);
return res;
}
GestureRecognizer _getRecognizer(Node segment) {
if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!;
}
if (isDesktop() || widget.readOnly) {
_linkRecognizers[segment] = TapGestureRecognizer()
..onTap = () => _tapNodeLink(segment);
} else {
_linkRecognizers[segment] = LongPressGestureRecognizer()
..onLongPress = () => _longPressLink(segment);
}
return _linkRecognizers[segment]!;
}
Future<void> _launchUrl(String url) async {
await launchUrl(Uri.parse(url));
}
void _tapNodeLink(Node node) {
final link = node.style.attributes[Attribute.link.key]!.value;
_tapLink(link);
}
void _tapLink(String? link) {
if (link == null) {
return;
}
var launchUrl = widget.onLaunchUrl;
launchUrl ??= _launchUrl;
link = link.trim();
if (!linkPrefixes
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
link = 'https://$link';
}
launchUrl(link);
}
Future<void> _longPressLink(Node node) async {
final link = node.style.attributes[Attribute.link.key]!.value!;
final action = await widget.linkActionPicker(node);
switch (action) {
case LinkMenuAction.launch:
_tapLink(link);
break;
case LinkMenuAction.copy:
// ignore: unawaited_futures
Clipboard.setData(ClipboardData(text: link));
break;
case LinkMenuAction.remove:
final range = getLinkRange(node);
widget.controller
.formatText(range.start, range.end - range.start, Attribute.link);
break;
case LinkMenuAction.none:
break;
}
}
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(
List.castFrom<dynamic, TextDecoration>(decorations)));
}
}
class EditableTextLine extends RenderObjectWidget {
const 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,
);
final Line line;
final Widget? leading;
final Widget body;
final double indentWidth;
final VerticalSpacing verticalSpacing;
final TextDirection textDirection;
final TextSelection textSelection;
final Color color;
final bool enableInteractiveSelection;
final bool hasFocus;
final double devicePixelRatio;
final CursorCont cursorCont;
@override
RenderObjectElement createElement() {
return _TextLineElement(this);
}
@override
RenderObject createRenderObject(BuildContext context) {
final defaultStyles = DefaultStyles.getInstance(context);
return RenderEditableTextLine(
line,
textDirection,
textSelection,
enableInteractiveSelection,
hasFocus,
devicePixelRatio,
_getPadding(),
color,
cursorCont,
defaultStyles.inlineCode!);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditableTextLine renderObject) {
final defaultStyles = DefaultStyles.getInstance(context);
renderObject
..setLine(line)
..setPadding(_getPadding())
..setTextDirection(textDirection)
..setTextSelection(textSelection)
..setColor(color)
..setEnableInteractiveSelection(enableInteractiveSelection)
..hasFocus = hasFocus
..setDevicePixelRatio(devicePixelRatio)
..setCursorCont(cursorCont)
..setInlineCodeStyle(defaultStyles.inlineCode!);
}
EdgeInsetsGeometry _getPadding() {
return EdgeInsetsDirectional.only(
start: indentWidth,
top: verticalSpacing.top,
bottom: verticalSpacing.bottom);
}
}
enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox {
/// Creates new editable paragraph render box.
RenderEditableTextLine(
this.line,
this.textDirection,
this.textSelection,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.padding,
this.color,
this.cursorCont,
this.inlineCodeStyle);
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;
late Rect _caretPrototype;
InlineCodeStyle inlineCodeStyle;
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{};
Iterable<RenderBox> get _children sync* {
if (_leading != null) {
yield _leading!;
}
if (_body != null) {
yield _body!;
}
}
void setCursorCont(CursorCont c) {
if (cursorCont == c) {
return;
}
cursorCont = c;
markNeedsLayout();
}
void setDevicePixelRatio(double d) {
if (devicePixelRatio == d) {
return;
}
devicePixelRatio = d;
markNeedsLayout();
}
void setEnableInteractiveSelection(bool val) {
if (enableInteractiveSelection == val) {
return;
}
markNeedsLayout();
markNeedsSemanticsUpdate();
}
void setColor(Color c) {
if (color == c) {
return;
}
color = c;
if (containsTextSelection()) {
safeMarkNeedsPaint();
}
}
void setTextSelection(TextSelection t) {
if (textSelection == t) {
return;
}
final containsSelection = containsTextSelection();
if (_attachedToCursorController) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(safeMarkNeedsPaint);
_attachedToCursorController = false;
}
textSelection = t;
_selectedRects = null;
_containsCursor = null;
if (attached && containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(safeMarkNeedsPaint);
_attachedToCursorController = true;
}
if (containsSelection || containsTextSelection()) {
safeMarkNeedsPaint();
}
}
void setTextDirection(TextDirection t) {
if (textDirection == t) {
return;
}
textDirection = t;
_resolvedPadding = null;
markNeedsLayout();
}
void setLine(Line l) {
if (line == l) {
return;
}
line = l;
_containsCursor = null;
markNeedsLayout();
}
void setPadding(EdgeInsetsGeometry p) {
assert(p.isNonNegative);
if (padding == p) {
return;
}
padding = p;
_resolvedPadding = null;
markNeedsLayout();
}
void setLeading(RenderBox? l) {
_leading = _updateChild(_leading, l, TextLineSlot.LEADING);
}
void setBody(RenderContentProxyBox? b) {
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?;
}
void setInlineCodeStyle(InlineCodeStyle newStyle) {
if (inlineCodeStyle == newStyle) return;
inlineCodeStyle = newStyle;
markNeedsLayout();
}
// Start selection implementation
bool containsTextSelection() {
return line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1;
}
bool containsCursor() {
return _containsCursor ??= cursorCont.isFloatingCursorActive
? line
.containsOffset(cursorCont.floatingCursorTextPosition.value!.offset)
: 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) {
final 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);
}
void _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, preferredLineHeight(textSelection.extent)) +
getOffsetForCaret(textSelection.extent),
null);
}
final boxes = _getBoxes(textSelection);
assert(boxes.isNotEmpty);
final targetBox = first ? boxes.first : boxes.last;
return TextSelectionPoint(
Offset(first ? targetBox.start : targetBox.end, targetBox.bottom),
targetBox.direction);
}
@override
TextRange getLineBoundary(TextPosition position) {
final lineDy = getOffsetForCaret(position)
.translate(0, 0.5 * preferredLineHeight(position))
.dy;
final 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);
}
@override
bool get isRepaintBoundary => true;
TextPosition? _getPosition(TextPosition textPosition, double dyScale) {
assert(textPosition.offset < line.length);
final 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!.preferredLineHeight;
}
@override
container_node.Container get container => line;
double get cursorWidth => cursorCont.style.width;
double get cursorHeight =>
cursorCont.style.height ??
preferredLineHeight(const TextPosition(offset: 0));
// TODO: This is no longer producing the highest-fidelity caret
// heights for Android, especially when non-alphabetic languages
// are involved. The current implementation overrides the height set
// here with the full measured height of the text on Android which looks
// superior (subjectively and in terms of fidelity) in _paintCaret. We
// should rework this properly to once again match the platform. The constant
// _kCaretHeightOffset scales poorly for small font sizes.
//
/// On iOS, the cursor is taller than the cursor on Android. The height
/// of the cursor for iOS is approximate and obtained through an eyeball
/// comparison.
void _computeCaretPrototype() {
if (isAppleOS()) {
_caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2);
} else {
_caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0);
}
}
void _onFloatingCursorChange() {
_containsCursor = null;
markNeedsPaint();
}
// End caret implementation
//
// Start render box overrides
bool _attachedToCursorController = false;
@override
void attach(covariant PipelineOwner owner) {
super.attach(owner);
for (final child in _children) {
child.attach(owner);
}
cursorCont.floatingCursorTextPosition.addListener(_onFloatingCursorChange);
if (containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(safeMarkNeedsPaint);
_attachedToCursorController = true;
}
}
@override
void detach() {
super.detach();
for (final child in _children) {
child.detach();
}
cursorCont.floatingCursorTextPosition
.removeListener(_onFloatingCursorChange);
if (_attachedToCursorController) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(safeMarkNeedsPaint);
_attachedToCursorController = false;
}
}
@override
void redepthChildren() {
_children.forEach(redepthChild);
}
@override
void visitChildren(RenderObjectVisitor visitor) {
_children.forEach(visitor);
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final 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();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
final leadingWidth = _leading == null
? 0
: _leading!.getMinIntrinsicWidth(height - verticalPadding).ceil();
final bodyWidth = _body == null
? 0
: _body!
.getMinIntrinsicWidth(math.max(0, height - verticalPadding))
.ceil();
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
final leadingWidth = _leading == null
? 0
: _leading!.getMaxIntrinsicWidth(height - verticalPadding).ceil();
final bodyWidth = _body == null
? 0
: _body!
.getMaxIntrinsicWidth(math.max(0, height - verticalPadding))
.ceil();
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
if (_body != null) {
return _body!
.getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeMaxIntrinsicHeight(double width) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
if (_body != null) {
return _body!
.getMaxIntrinsicHeight(math.max(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);
(_body!.parentData as BoxParentData).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);
(_leading!.parentData as BoxParentData).offset =
Offset(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(
editable: _body,
style: cursorCont.style,
prototype: _caretPrototype,
color: cursorCont.isFloatingCursorActive
? cursorCont.style.backgroundColor
: cursorCont.color.value,
devicePixelRatio: devicePixelRatio,
);
@override
void 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 (inlineCodeStyle.backgroundColor != null) {
for (final item in line.children) {
if (item is! leaf.Text ||
!item.style.containsKey(Attribute.inlineCode.key)) {
continue;
}
final textRange = TextSelection(
baseOffset: item.offset, extentOffset: item.offset + item.length);
final rects = _body!.getBoxesForSelection(textRange);
final paint = Paint()..color = inlineCodeStyle.backgroundColor!;
for (final box in rects) {
final rect = box.toRect().translate(0, 1).shift(effectiveOffset);
if (inlineCodeStyle.radius == null) {
final paintRect = Rect.fromLTRB(
rect.left - 2, rect.top, rect.right + 2, rect.bottom);
context.canvas.drawRect(paintRect, paint);
} else {
final paintRect = RRect.fromLTRBR(rect.left - 2, rect.top,
rect.right + 2, rect.bottom, inlineCodeStyle.radius!);
context.canvas.drawRRect(paintRect, paint);
}
}
}
}
if (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
!cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset, line.hasEmbed);
}
context.paintChild(_body!, effectiveOffset);
if (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset, line.hasEmbed);
}
// paint the selection on the top
if (enableInteractiveSelection &&
line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1) {
final local = localSelection(line, textSelection, false);
_selectedRects ??= _body!.getBoxesForSelection(
local,
);
// Paint a small rect at the start of empty lines that
// are contained by the selection.
if (line.isEmpty &&
textSelection.baseOffset <= line.offset &&
textSelection.extentOffset > line.offset
) {
final lineHeight =
preferredLineHeight(TextPosition(offset: line.offset));
_selectedRects?.add(
TextBox.fromLTRBD(0, 0, 3, lineHeight, textDirection));
}
_paintSelection(context, effectiveOffset);
}
}
}
void _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);
}
}
void _paintCursor(
PaintingContext context, Offset effectiveOffset, bool lineHasEmbed) {
final position = cursorCont.isFloatingCursorActive
? TextPosition(
offset: cursorCont.floatingCursorTextPosition.value!.offset -
line.documentOffset,
affinity: cursorCont.floatingCursorTextPosition.value!.affinity)
: TextPosition(
offset: textSelection.extentOffset - line.documentOffset,
affinity: textSelection.base.affinity);
_cursorPainter.paint(
context.canvas, effectiveOffset, position, lineHasEmbed);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
if (_leading != null) {
final childParentData = _leading!.parentData as BoxParentData;
final isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (result, transformed) {
assert(transformed == position - childParentData.offset);
return _leading!.hitTest(result, position: transformed);
});
if (isHit) return true;
}
if (_body == null) return false;
final parentData = _body!.parentData as BoxParentData;
return result.addWithPaintOffset(
offset: parentData.offset,
position: position,
hitTest: (result, position) {
return _body!.hitTest(result, position: position);
});
}
@override
Rect getLocalRectForCaret(TextPosition position) {
final caretOffset = getOffsetForCaret(position);
var rect =
Rect.fromLTWH(0, 0, cursorWidth, cursorHeight).shift(caretOffset);
final cursorOffset = cursorCont.style.offset;
// Add additional cursor offset (generally only if on iOS).
if (cursorOffset != null) rect = rect.shift(cursorOffset);
return rect;
}
@override
TextPosition globalToLocalPosition(TextPosition position) {
assert(container.containsOffset(position.offset),
'The provided text position is not in the current node');
return TextPosition(
offset: position.offset - container.documentOffset,
affinity: position.affinity,
);
}
void safeMarkNeedsPaint() {
if (!attached) {
//Should not paint if it was unattached.
return;
}
markNeedsPaint();
}
@override
Rect getCaretPrototype(TextPosition position) => _caretPrototype;
}
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
void visitChildren(ElementVisitor visitor) {
_slotToChildren.values.forEach(visitor);
}
@override
void 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
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
_mountChild(widget.leading, TextLineSlot.LEADING);
_mountChild(widget.body, TextLineSlot.BODY);
}
@override
void update(EditableTextLine newWidget) {
super.update(newWidget);
assert(widget == newWidget);
_updateChild(widget.leading, TextLineSlot.LEADING);
_updateChild(widget.body, TextLineSlot.BODY);
}
@override
void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) {
// assert(child is RenderBox);
_updateRenderObject(child, slot);
assert(renderObject.children.keys.contains(slot));
}
@override
void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) {
assert(child is RenderBox);
assert(renderObject.children[slot!] == child);
_updateRenderObject(null, slot);
assert(!renderObject.children.keys.contains(slot));
}
@override
void moveRenderObjectChild(
RenderObject child, dynamic oldSlot, dynamic newSlot) {
throw UnimplementedError();
}
void _mountChild(Widget? widget, TextLineSlot slot) {
final oldChild = _slotToChildren[slot];
final newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
void _updateRenderObject(RenderBox? child, TextLineSlot? slot) {
switch (slot) {
case TextLineSlot.LEADING:
renderObject.setLeading(child);
break;
case TextLineSlot.BODY:
renderObject.setBody(child as RenderContentProxyBox?);
break;
default:
throw UnimplementedError();
}
}
void _updateChild(Widget? widget, TextLineSlot slot) {
final oldChild = _slotToChildren[slot];
final newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
}