From 37fc173dde60c5dabcbdf2c6c7009c0e23159670 Mon Sep 17 00:00:00 2001 From: singerdmx Date: Thu, 17 Dec 2020 14:55:55 -0800 Subject: [PATCH] Add default styles and text line --- lib/models/documents/nodes/line.dart | 8 + lib/models/documents/nodes/node.dart | 24 ++ lib/models/documents/style.dart | 2 + lib/widgets/box.dart | 38 ++ lib/widgets/cursor.dart | 105 ++++- lib/widgets/default_styles.dart | 134 +++++++ lib/widgets/proxy.dart | 292 ++++++++++++++ lib/widgets/text_block.dart | 37 ++ lib/widgets/text_line.dart | 552 +++++++++++++++++++++++++++ 9 files changed, 1178 insertions(+), 14 deletions(-) create mode 100644 lib/widgets/box.dart create mode 100644 lib/widgets/default_styles.dart create mode 100644 lib/widgets/proxy.dart create mode 100644 lib/widgets/text_block.dart create mode 100644 lib/widgets/text_line.dart diff --git a/lib/models/documents/nodes/line.dart b/lib/models/documents/nodes/line.dart index f133b89c..0f7c7848 100644 --- a/lib/models/documents/nodes/line.dart +++ b/lib/models/documents/nodes/line.dart @@ -17,6 +17,14 @@ class Line extends Container { @override int get length => super.length + 1; + bool get hasEmbed { + if (childCount != 1) { + return false; + } + + return children.single is Embed; + } + Line get nextLine { if (!isLast) { return next is Block ? (next as Block).first : next; diff --git a/lib/models/documents/nodes/node.dart b/lib/models/documents/nodes/node.dart index 48ba63d7..91d59687 100644 --- a/lib/models/documents/nodes/node.dart +++ b/lib/models/documents/nodes/node.dart @@ -41,6 +41,30 @@ abstract class Node extends LinkedListEntry { return node; } + int getOffset() { + int offset = 0; + + if (isFirst) { + return offset; + } + + Node cur = this; + do { + cur = cur.previous; + offset += cur.length; + } while (!cur.isFirst); + return offset; + } + + int getDocumentOffset() { + return parent is! Root ? parent.getDocumentOffset() : 0 + getOffset(); + } + + bool containsOffset(int offset) { + return getDocumentOffset() <= offset && + offset < getDocumentOffset() + this.length; + } + @override void insertBefore(Node entry) { assert(entry.parent == null && parent != null); diff --git a/lib/models/documents/style.dart b/lib/models/documents/style.dart index 6571ed0f..b9855107 100644 --- a/lib/models/documents/style.dart +++ b/lib/models/documents/style.dart @@ -29,6 +29,8 @@ class Style { Iterable get values => _attributes.values; + Map get attributes => _attributes; + bool get isEmpty => _attributes.isEmpty; bool get isNotEmpty => _attributes.isNotEmpty; diff --git a/lib/widgets/box.dart b/lib/widgets/box.dart new file mode 100644 index 00000000..9a28d1e1 --- /dev/null +++ b/lib/widgets/box.dart @@ -0,0 +1,38 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter_quill/models/documents/nodes/container.dart'; + +abstract class RenderContentProxyBox implements RenderBox { + double getPreferredLineHeight(); + + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype); + + TextPosition getPositionForOffset(Offset offset); + + double getFullHeightForCaret(TextPosition position); + + TextRange getWordBoundary(TextPosition position); + + List getBoxesForSelection(TextSelection textSelection); +} + +abstract class RenderEditableBox extends RenderBox { + Container getContainer(); + + double preferredLineHeight(TextPosition position); + + Offset getOffsetForCaret(TextPosition position); + + TextPosition getPositionForOffset(Offset offset); + + TextPosition getPositionAbove(TextPosition position); + + TextPosition getPositionBelow(TextPosition position); + + TextRange getWordBoundary(TextPosition position); + + TextRange getLineBoundary(TextPosition position); + + TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection); + + TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection); +} diff --git a/lib/widgets/cursor.dart b/lib/widgets/cursor.dart index 481424b1..f4c7b9ee 100644 --- a/lib/widgets/cursor.dart +++ b/lib/widgets/cursor.dart @@ -1,5 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'box.dart'; + const Duration _FADE_DURATION = Duration(milliseconds: 250); class CursorStyle { @@ -29,16 +32,16 @@ class CursorStyle { @override bool operator ==(Object other) => identical(this, other) || - other is CursorStyle && - runtimeType == other.runtimeType && - color == other.color && - backgroundColor == other.backgroundColor && - width == other.width && - height == other.height && - radius == other.radius && - offset == other.offset && - opacityAnimates == other.opacityAnimates && - paintAboveText == other.paintAboveText; + other is CursorStyle && + runtimeType == other.runtimeType && + color == other.color && + backgroundColor == other.backgroundColor && + width == other.width && + height == other.height && + radius == other.radius && + offset == other.offset && + opacityAnimates == other.opacityAnimates && + paintAboveText == other.paintAboveText; @override int get hashCode => @@ -53,10 +56,9 @@ class CursorStyle { } class CursorCont extends ChangeNotifier { - final ValueNotifier show; final ValueNotifier _blink; - final ValueNotifier _color; + final ValueNotifier color; AnimationController _blinkOpacityCont; CursorStyle _style; @@ -70,14 +72,89 @@ class CursorCont extends ChangeNotifier { show = show ?? ValueNotifier(false), _style = style, _blink = ValueNotifier(false), - _color = ValueNotifier(style.color) { + color = ValueNotifier(style.color) { _blinkOpacityCont = AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); _blinkOpacityCont.addListener(_onColorTick); } void _onColorTick() { - _color.value = _style.color.withOpacity(_blinkOpacityCont.value); + color.value = _style.color.withOpacity(_blinkOpacityCont.value); _blink.value = show.value && _blinkOpacityCont.value > 0; } } + +class CursorPainter { + final RenderContentProxyBox editable; + final CursorStyle style; + final Rect prototype; + final Color color; + final double devicePixelRatio; + + CursorPainter(this.editable, this.style, this.prototype, this.color, + this.devicePixelRatio); + + paint(Canvas canvas, Offset offset, TextPosition position) { + assert(prototype != null); + + Offset caretOffset = + editable.getOffsetForCaret(position, prototype) + offset; + Rect caretRect = prototype.shift(caretOffset); + if (style.offset != null) { + caretRect = caretRect.shift(style.offset); + } + + if (caretRect.left < 0.0) { + caretRect = caretRect.shift(Offset(-caretRect.left, 0.0)); + } + + double caretHeight = editable.getFullHeightForCaret(position); + if (caretHeight != null) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top - 2.0, + caretRect.width, + caretHeight, + ); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top + (caretHeight - caretRect.height) / 2, + caretRect.width, + caretRect.height, + ); + break; + default: + throw UnimplementedError(); + } + } + + Offset caretPosition = editable.localToGlobal(caretRect.topLeft); + double pixelMultiple = 1.0 / devicePixelRatio; + caretRect = caretRect.shift(Offset( + caretPosition.dx.isFinite + ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - + caretPosition.dx + : caretPosition.dx, + caretPosition.dy.isFinite + ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - + caretPosition.dy + : caretPosition.dy)); + + Paint paint = Paint()..color = color; + if (style.radius == null) { + canvas.drawRect(caretRect, paint); + return; + } + + RRect caretRRect = RRect.fromRectAndRadius(caretRect, style.radius); + canvas.drawRRect(caretRRect, paint); + } +} diff --git a/lib/widgets/default_styles.dart b/lib/widgets/default_styles.dart new file mode 100644 index 00000000..5b4f8d1a --- /dev/null +++ b/lib/widgets/default_styles.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:tuple/tuple.dart'; + +class DefaultTextBlockStyle { + final TextStyle style; + + final Tuple2 verticalSpacing; + + final Tuple2 lineSpacing; + + final BoxDecoration decoration; + + DefaultTextBlockStyle( + this.style, this.verticalSpacing, this.lineSpacing, this.decoration); +} + +class DefaultStyles { + final DefaultTextBlockStyle h1; + final DefaultTextBlockStyle h2; + final DefaultTextBlockStyle h3; + final DefaultTextBlockStyle paragraph; + final TextStyle bold; + final TextStyle italic; + final TextStyle underline; + final TextStyle strikeThrough; + final TextStyle link; + final DefaultTextBlockStyle lists; + final DefaultTextBlockStyle quote; + final DefaultTextBlockStyle code; + + DefaultStyles( + this.h1, + this.h2, + this.h3, + this.paragraph, + this.bold, + this.italic, + this.underline, + this.strikeThrough, + this.link, + this.lists, + this.quote, + this.code); + + static DefaultStyles getInstance(BuildContext context) { + ThemeData themeData = Theme.of(context); + DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + TextStyle baseStyle = defaultTextStyle.style.copyWith( + fontSize: 16.0, + height: 1.3, + ); + Tuple2 baseSpacing = Tuple2(6.0, 10); + String fontFamily; + switch (themeData.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + fontFamily = 'Menlo'; + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + case TargetPlatform.linux: + fontFamily = 'Roboto Mono'; + break; + default: + throw UnimplementedError(); + } + + return DefaultStyles( + DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 34.0, + color: defaultTextStyle.style.color.withOpacity(0.70), + height: 1.15, + fontWeight: FontWeight.w300, + ), + Tuple2(16.0, 0.0), + Tuple2(0.0, 0.0), + null), + DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 24.0, + color: defaultTextStyle.style.color.withOpacity(0.70), + height: 1.15, + fontWeight: FontWeight.normal, + ), + Tuple2(8.0, 0.0), + Tuple2(0.0, 0.0), + null), + DefaultTextBlockStyle( + defaultTextStyle.style.copyWith( + fontSize: 20.0, + color: defaultTextStyle.style.color.withOpacity(0.70), + height: 1.25, + fontWeight: FontWeight.w500, + ), + Tuple2(8.0, 0.0), + Tuple2(0.0, 0.0), + null), + DefaultTextBlockStyle(baseStyle, baseSpacing, Tuple2(0.0, 0.0), null), + TextStyle(fontWeight: FontWeight.bold), + TextStyle(fontStyle: FontStyle.italic), + TextStyle(decoration: TextDecoration.underline), + TextStyle(decoration: TextDecoration.lineThrough), + TextStyle( + color: themeData.accentColor, + decoration: TextDecoration.underline, + ), + DefaultTextBlockStyle(baseStyle, baseSpacing, Tuple2(0.0, 6.0), null), + DefaultTextBlockStyle( + TextStyle(color: baseStyle.color.withOpacity(0.6)), + baseSpacing, + Tuple2(6.0, 2.0), + BoxDecoration( + border: Border( + left: BorderSide(width: 4, color: Colors.grey.shade300), + ), + )), + DefaultTextBlockStyle( + TextStyle( + color: Colors.blue.shade900.withOpacity(0.9), + fontFamily: fontFamily, + fontSize: 13.0, + height: 1.15, + ), + baseSpacing, + Tuple2(0.0, 0.0), + BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(2), + ))); + } +} diff --git a/lib/widgets/proxy.dart b/lib/widgets/proxy.dart new file mode 100644 index 00000000..975ef7a3 --- /dev/null +++ b/lib/widgets/proxy.dart @@ -0,0 +1,292 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'box.dart'; + +class BaselineProxy extends SingleChildRenderObjectWidget { + final TextStyle textStyle; + final EdgeInsets padding; + + BaselineProxy({Key key, Widget child, this.textStyle, this.padding}) + : super(key: key, child: child); + + @override + RenderBaselineProxy createRenderObject(BuildContext context) { + return RenderBaselineProxy( + null, + textStyle, + padding, + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderBaselineProxy renderObject) { + renderObject + ..textStyle = textStyle + ..padding = padding; + } +} + +class RenderBaselineProxy extends RenderProxyBox { + RenderBaselineProxy( + RenderParagraph child, + TextStyle textStyle, + EdgeInsets padding, + ) : _prototypePainter = TextPainter( + text: TextSpan(text: ' ', style: textStyle), + textDirection: TextDirection.ltr, + strutStyle: + StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)), + super(child); + + final TextPainter _prototypePainter; + + set textStyle(TextStyle value) { + assert(value != null); + if (_prototypePainter.text.style == value) { + return; + } + _prototypePainter.text = TextSpan(text: ' ', style: value); + markNeedsLayout(); + } + + EdgeInsets _padding; + + set padding(EdgeInsets value) { + assert(value != null); + if (_padding == value) { + return; + } + _padding = value; + markNeedsLayout(); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) => + _prototypePainter.computeDistanceToActualBaseline(baseline) + + _padding?.top ?? + 0.0; + + @override + void performLayout() { + super.performLayout(); + _prototypePainter.layout(); + } +} + +class EmbedProxy extends SingleChildRenderObjectWidget { + EmbedProxy(Widget child) : super(child: child); + + @override + RenderEmbedProxy createRenderObject(BuildContext context) => + RenderEmbedProxy(null); +} + +class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { + RenderEmbedProxy(RenderBox child) : super(child); + + @override + List getBoxesForSelection(TextSelection selection) { + if (!selection.isCollapsed) { + return [ + TextBox.fromLTRBD(0.0, 0.0, size.width, size.height, TextDirection.ltr) + ]; + } + + double left = selection.extentOffset == 0 ? 0.0 : size.width; + double right = selection.extentOffset == 0 ? 0.0 : size.width; + return [ + TextBox.fromLTRBD(left, 0.0, right, size.height, TextDirection.ltr) + ]; + } + + @override + double getFullHeightForCaret(TextPosition position) => size.height; + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + assert(position.offset != null && + position.offset <= 1 && + position.offset >= 0); + return position.offset == 0 ? Offset.zero : Offset(size.width, 0.0); + } + + @override + TextPosition getPositionForOffset(Offset offset) => + TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0); + + @override + TextRange getWordBoundary(TextPosition position) => + TextRange(start: 0, end: 1); + + @override + double getPreferredLineHeight() { + return size.height; + } +} + +class RichTextProxy extends SingleChildRenderObjectWidget { + final TextStyle textStyle; + final TextDirection textDirection; + final double textScaleFactor; + final Locale locale; + final StrutStyle strutStyle; + final TextWidthBasis textWidthBasis; + final TextHeightBehavior textHeightBehavior; + + @override + RenderParagraphProxy createRenderObject(BuildContext context) { + return RenderParagraphProxy(null, textStyle, textDirection, textScaleFactor, + strutStyle, locale, textWidthBasis, textHeightBehavior); + } + + RichTextProxy( + RichText child, + this.textStyle, + this.textDirection, + this.textScaleFactor, + this.locale, + this.strutStyle, + this.textWidthBasis, + this.textHeightBehavior) + : assert(child != null), + assert(textStyle != null), + assert(textDirection != null), + assert(locale != null), + assert(strutStyle != null), + super(child: child); + + @override + void updateRenderObject( + BuildContext context, covariant RenderParagraphProxy renderObject) { + renderObject.textStyle = textStyle; + renderObject.textDirection = textDirection; + renderObject.textScaleFactor = textScaleFactor; + renderObject.locale = locale; + renderObject.strutStyle = strutStyle; + renderObject.textWidthBasis = textWidthBasis; + renderObject.textHeightBehavior = textHeightBehavior; + } +} + +class RenderParagraphProxy extends RenderProxyBox + implements RenderContentProxyBox { + RenderParagraphProxy( + RenderParagraph child, + TextStyle textStyle, + TextDirection textDirection, + double textScaleFactor, + StrutStyle strutStyle, + Locale locale, + TextWidthBasis textWidthBasis, + TextHeightBehavior textHeightBehavior, + ) : _prototypePainter = TextPainter( + text: TextSpan(text: ' ', style: textStyle), + textAlign: TextAlign.left, + textDirection: textDirection, + textScaleFactor: textScaleFactor, + strutStyle: strutStyle, + locale: locale, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior), + super(child); + + final TextPainter _prototypePainter; + + set textStyle(TextStyle value) { + assert(value != null); + if (_prototypePainter.text.style == value) { + return; + } + _prototypePainter.text = TextSpan(text: ' ', style: value); + markNeedsLayout(); + } + + set textDirection(TextDirection value) { + assert(value != null); + if (_prototypePainter.textDirection == value) { + return; + } + _prototypePainter.textDirection = value; + markNeedsLayout(); + } + + set textScaleFactor(double value) { + assert(value != null); + if (_prototypePainter.textScaleFactor == value) { + return; + } + _prototypePainter.textScaleFactor = value; + markNeedsLayout(); + } + + set strutStyle(StrutStyle value) { + assert(value != null); + if (_prototypePainter.strutStyle == value) { + return; + } + _prototypePainter.strutStyle = value; + markNeedsLayout(); + } + + set locale(Locale value) { + if (_prototypePainter.locale == value) { + return; + } + _prototypePainter.locale = value; + markNeedsLayout(); + } + + set textWidthBasis(TextWidthBasis value) { + assert(value != null); + if (_prototypePainter.textWidthBasis == value) { + return; + } + _prototypePainter.textWidthBasis = value; + markNeedsLayout(); + } + + set textHeightBehavior(TextHeightBehavior value) { + if (_prototypePainter.textHeightBehavior == value) { + return; + } + _prototypePainter.textHeightBehavior = value; + markNeedsLayout(); + } + + @override + RenderParagraph get child => super.child; + + @override + double getPreferredLineHeight() { + return _prototypePainter.preferredLineHeight; + } + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) => + child.getOffsetForCaret(position, caretPrototype); + + @override + TextPosition getPositionForOffset(Offset offset) => + child.getPositionForOffset(offset); + + @override + double getFullHeightForCaret(TextPosition position) => + child.getFullHeightForCaret(position); + + @override + TextRange getWordBoundary(TextPosition position) => + child.getWordBoundary(position); + + @override + List getBoxesForSelection(TextSelection selection) => + child.getBoxesForSelection(selection); + + @override + performLayout() { + super.performLayout(); + _prototypePainter.layout( + minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + } +} diff --git a/lib/widgets/text_block.dart b/lib/widgets/text_block.dart new file mode 100644 index 00000000..ac57123e --- /dev/null +++ b/lib/widgets/text_block.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/models/documents/nodes/block.dart'; +import 'package:tuple/tuple.dart'; + +import 'box.dart'; + +class EditableTextBlock extends StatelessWidget { + final Block block; + final TextDirection textDirection; + final Tuple2 verticalSpacing; + final TextSelection textSelection; + final Color color; + final bool enableInteractiveSelection; + final bool hasFocus; + final EdgeInsets contentPadding; + + EditableTextBlock( + this.block, + this.textDirection, + this.verticalSpacing, + this.textSelection, + this.color, + this.enableInteractiveSelection, + this.hasFocus, + this.contentPadding); + + @override + Widget build(BuildContext context) { + // TODO: implement build + throw UnimplementedError(); + } +} + +//class RenderEditableTextBlock extends RenderEditableContainerBox +// implements RenderEditableBox { +// +// } diff --git a/lib/widgets/text_line.dart b/lib/widgets/text_line.dart new file mode 100644 index 00000000..02def001 --- /dev/null +++ b/lib/widgets/text_line.dart @@ -0,0 +1,552 @@ +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/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:tuple/tuple.dart'; + +import 'box.dart'; +import 'default_styles.dart'; + +class TextLine extends StatelessWidget { + final Line line; + final TextDirection textDirection; + + const TextLine({Key key, this.line, this.textDirection}) + : assert(line != null), + super(key: key); + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + // TODO: line.hasEmbed + + TextSpan textSpan = _buildTextSpan(context); + StrutStyle strutStyle = + StrutStyle.fromTextStyle(textSpan.style, forceStrutHeight: true); + return RichTextProxy( + RichText( + text: _buildTextSpan(context), + textDirection: textDirection, + strutStyle: strutStyle, + textScaleFactor: MediaQuery.textScaleFactorOf(context), + ), + textSpan.style, + textDirection, + 1.0, + Localizations.localeOf(context, nullOk: true), + strutStyle, + TextWidthBasis.parent, + null); + } + + TextSpan _buildTextSpan(BuildContext context) { + DefaultStyles defaultStyles = DefaultStyles.getInstance(context); + List children = + line.children.map((node) => _getTextSpanFromNode(defaultStyles, node)); + + TextStyle textStyle = TextStyle(); + + Attribute header = line.style.attributes[Attribute.header.key]; + Map 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); + } + + TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { + leaf.Text textNode = node as leaf.Text; + Style style = textNode.style; + TextStyle res = TextStyle(); + + Map m = { + Attribute.bold: defaultStyles.bold, + Attribute.italic: defaultStyles.italic, + Attribute.link: defaultStyles.link, + Attribute.underline: defaultStyles.underline, + Attribute.strikeThrough: defaultStyles.strikeThrough, + }; + m.forEach((k, s) { + if (style.values.any((v) => v == k)) { + res = _merge(res, s); + } + }); + + return TextSpan(text: textNode.value, style: res); + } + + TextStyle _merge(TextStyle a, TextStyle b) { + final decorations = []; + 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(), + 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 _selectedRects; + Rect _caretPrototype; + final Map children = {}; + + RenderEditableTextLine( + this.line, + this.textDirection, + this.textSelection, + this.enableInteractiveSelection, + this.hasFocus, + this.devicePixelRatio, + this.padding, + this.cursorCont) + : assert(line != null), + assert(padding != null), + assert(padding.isNonNegative), + assert(devicePixelRatio != null), + assert(hasFocus != null), + assert(cursorCont != null); + + 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 _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 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 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; + } +} + +class _TextLineElement extends RenderObjectElement { + _TextLineElement(EditableTextLine line) : super(line); + + final Map _slotToChildren = {}; + + @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.leading = child as RenderBox; + break; + case TextLineSlot.BODY: + renderObject.body = 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; + } + } +}