From 7b8c5ddfe5fb987c224ff01d4b9bd560bc81d9d8 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Mon, 23 Sep 2024 15:00:43 -0400 Subject: [PATCH 1/2] Chore: move editor text elements into separate files when possible --- lib/flutter_quill.dart | 2 +- lib/src/editor/editor.dart | 3 +- lib/src/editor/raw_editor/raw_editor.dart | 2 +- .../editor/raw_editor/raw_editor_state.dart | 7 +- lib/src/editor/widgets/default_styles.dart | 2 +- lib/src/editor/widgets/delegate.dart | 2 +- .../text/block_line/editable_text_block.dart | 63 + .../render_editable_text_block.dart | 314 ++++ .../text/{ => block_line}/text_block.dart | 471 +---- .../utils/text_block_utils.dart | 8 +- .../widgets/text/line/editable_text_line.dart | 87 + .../text/line/render_editable_text_line.dart | 756 ++++++++ .../text/line/render_text_line_element.dart | 114 ++ .../editor/widgets/text/line/text_line.dart | 692 +++++++ .../text/selection/drag_text_selection.dart | 33 + .../text/{ => selection}/text_selection.dart | 36 +- lib/src/editor/widgets/text/text_line.dart | 1584 ----------------- 17 files changed, 2129 insertions(+), 2047 deletions(-) create mode 100644 lib/src/editor/widgets/text/block_line/editable_text_block.dart create mode 100644 lib/src/editor/widgets/text/block_line/render_editable_text_block.dart rename lib/src/editor/widgets/text/{ => block_line}/text_block.dart (50%) rename lib/src/editor/widgets/text/{ => block_line}/utils/text_block_utils.dart (90%) create mode 100644 lib/src/editor/widgets/text/line/editable_text_line.dart create mode 100644 lib/src/editor/widgets/text/line/render_editable_text_line.dart create mode 100644 lib/src/editor/widgets/text/line/render_text_line_element.dart create mode 100644 lib/src/editor/widgets/text/line/text_line.dart create mode 100644 lib/src/editor/widgets/text/selection/drag_text_selection.dart rename lib/src/editor/widgets/text/{ => selection}/text_selection.dart (97%) delete mode 100644 lib/src/editor/widgets/text/text_line.dart diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 868eed6ef..60e6d272e 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -32,7 +32,7 @@ export 'src/editor/style_widgets/style_widgets.dart'; export 'src/editor/widgets/cursor.dart'; export 'src/editor/widgets/default_styles.dart'; export 'src/editor/widgets/link.dart'; -export 'src/editor/widgets/text/utils/text_block_utils.dart'; +export 'src/editor/widgets/text/block_line/utils/text_block_utils.dart'; export 'src/editor_toolbar_controller_shared/copy_cut_service/copy_cut_service.dart'; export 'src/editor_toolbar_controller_shared/copy_cut_service/copy_cut_service_provider.dart'; export 'src/editor_toolbar_controller_shared/copy_cut_service/default_copy_cut_service.dart'; diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 617d88fbc..2b12c3c73 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -32,7 +32,8 @@ import 'widgets/box.dart'; import 'widgets/cursor.dart'; import 'widgets/delegate.dart'; import 'widgets/float_cursor.dart'; -import 'widgets/text/text_selection.dart'; +import 'widgets/text/selection/drag_text_selection.dart'; +import 'widgets/text/selection/text_selection.dart'; /// Base interface for editable render objects. abstract class RenderAbstractEditor implements TextLayoutMetrics { diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index 8ae7d9399..40b5af6f3 100644 --- a/lib/src/editor/raw_editor/raw_editor.dart +++ b/lib/src/editor/raw_editor/raw_editor.dart @@ -14,7 +14,7 @@ import 'package:flutter/widgets.dart' import '../../common/structs/offset_value.dart'; import '../../controller/quill_controller.dart'; import '../editor.dart'; -import '../widgets/text/text_selection.dart'; +import '../widgets/text/selection/text_selection.dart'; import 'config/raw_editor_configurations.dart'; import 'raw_editor_state.dart'; diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index f9c071a4d..d5948a328 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -32,9 +32,10 @@ import '../widgets/default_styles.dart'; import '../widgets/keyboard_service_widget.dart'; import '../widgets/link.dart'; import '../widgets/proxy.dart'; -import '../widgets/text/text_block.dart'; -import '../widgets/text/text_line.dart'; -import '../widgets/text/text_selection.dart'; +import '../widgets/text/block_line/text_block.dart'; +import '../widgets/text/line/editable_text_line.dart'; +import '../widgets/text/line/text_line.dart'; +import '../widgets/text/selection/text_selection.dart'; import 'raw_editor.dart'; import 'raw_editor_actions.dart'; import 'raw_editor_render_object.dart'; diff --git a/lib/src/editor/widgets/default_styles.dart b/lib/src/editor/widgets/default_styles.dart index 0d55366e7..db069b846 100644 --- a/lib/src/editor/widgets/default_styles.dart +++ b/lib/src/editor/widgets/default_styles.dart @@ -6,7 +6,7 @@ import '../../common/utils/platform.dart'; import '../../document/attribute.dart'; import '../../document/style.dart'; import '../style_widgets/checkbox_point.dart'; -import 'text/utils/text_block_utils.dart'; +import 'text/block_line/utils/text_block_utils.dart'; class QuillStyles extends InheritedWidget { const QuillStyles({ diff --git a/lib/src/editor/widgets/delegate.dart b/lib/src/editor/widgets/delegate.dart index 836e7e37b..0683dcf10 100644 --- a/lib/src/editor/widgets/delegate.dart +++ b/lib/src/editor/widgets/delegate.dart @@ -10,7 +10,7 @@ import '../../document/attribute.dart'; import '../../document/nodes/leaf.dart'; import '../editor.dart'; import '../raw_editor/raw_editor.dart'; -import 'text/text_selection.dart'; +import 'text/selection/text_selection.dart'; typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); diff --git a/lib/src/editor/widgets/text/block_line/editable_text_block.dart b/lib/src/editor/widgets/text/block_line/editable_text_block.dart new file mode 100644 index 000000000..95303d302 --- /dev/null +++ b/lib/src/editor/widgets/text/block_line/editable_text_block.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +import '../../../../common/structs/horizontal_spacing.dart'; +import '../../../../common/structs/vertical_spacing.dart'; +import '../../../../document/nodes/block.dart'; +import 'render_editable_text_block.dart'; + +//TODO: we need to document what does this and why we use it +@internal +@immutable +class EditableBlock extends MultiChildRenderObjectWidget { + const EditableBlock( + {required this.block, + required this.textDirection, + required this.horizontalSpacing, + required this.verticalSpacing, + required this.scrollBottomInset, + required this.decoration, + required this.contentPadding, + required super.children, + super.key}); + + final Block block; + final TextDirection textDirection; + final HorizontalSpacing horizontalSpacing; + final VerticalSpacing verticalSpacing; + final double scrollBottomInset; + final Decoration decoration; + final EdgeInsets? contentPadding; + + EdgeInsets get _padding => EdgeInsets.only( + left: horizontalSpacing.left, + right: horizontalSpacing.right, + top: verticalSpacing.top, + bottom: verticalSpacing.bottom); + + EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; + + @override + RenderEditableTextBlock createRenderObject(BuildContext context) { + return RenderEditableTextBlock( + block: block, + textDirection: textDirection, + padding: _padding, + scrollBottomInset: scrollBottomInset, + decoration: decoration, + contentPadding: _contentPadding, + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderEditableTextBlock renderObject) { + renderObject + ..setContainer(block) + ..textDirection = textDirection + ..scrollBottomInset = scrollBottomInset + ..setPadding(_padding) + ..decoration = decoration + ..contentPadding = _contentPadding; + } +} diff --git a/lib/src/editor/widgets/text/block_line/render_editable_text_block.dart b/lib/src/editor/widgets/text/block_line/render_editable_text_block.dart new file mode 100644 index 000000000..46a2f0a3e --- /dev/null +++ b/lib/src/editor/widgets/text/block_line/render_editable_text_block.dart @@ -0,0 +1,314 @@ +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; +import '../../../../document/nodes/block.dart'; +import '../../../editor.dart' show RenderEditableContainerBox; +import '../../box.dart' show RenderEditableBox; +import '../selection/text_selection.dart'; + +@internal +class RenderEditableTextBlock extends RenderEditableContainerBox + implements RenderEditableBox { + RenderEditableTextBlock({ + required Block block, + required super.textDirection, + required EdgeInsetsGeometry padding, + required super.scrollBottomInset, + required Decoration decoration, + super.children, + EdgeInsets contentPadding = EdgeInsets.zero, + }) : _decoration = decoration, + _configuration = ImageConfiguration(textDirection: textDirection), + _savedPadding = padding, + _contentPadding = contentPadding, + super( + container: block, + padding: padding.add(contentPadding), + ); + + EdgeInsetsGeometry _savedPadding; + EdgeInsets _contentPadding; + + set contentPadding(EdgeInsets value) { + if (_contentPadding == value) return; + _contentPadding = value; + super.setPadding(_savedPadding.add(_contentPadding)); + } + + @override + void setPadding(EdgeInsetsGeometry value) { + super.setPadding(value.add(_contentPadding)); + _savedPadding = value; + } + + BoxPainter? _painter; + + Decoration get decoration => _decoration; + Decoration _decoration; + + set decoration(Decoration value) { + if (value == _decoration) return; + _painter?.dispose(); + _painter = null; + _decoration = value; + markNeedsPaint(); + } + + ImageConfiguration get configuration => _configuration; + ImageConfiguration _configuration; + + set configuration(ImageConfiguration value) { + if (value == _configuration) return; + _configuration = value; + markNeedsPaint(); + } + + @override + TextRange getLineBoundary(TextPosition position) { + final child = childAtPosition(position); + final rangeInChild = child.getLineBoundary(TextPosition( + offset: position.offset - child.container.offset, + affinity: position.affinity, + )); + return TextRange( + start: rangeInChild.start + child.container.offset, + end: rangeInChild.end + child.container.offset, + ); + } + + @override + Offset getOffsetForCaret(TextPosition position) { + final child = childAtPosition(position); + return child.getOffsetForCaret(TextPosition( + offset: position.offset - child.container.offset, + affinity: position.affinity, + )) + + (child.parentData as BoxParentData).offset; + } + + @override + TextPosition getPositionForOffset(Offset offset) { + final child = childAtOffset(offset); + final parentData = child.parentData as BoxParentData; + final localPosition = + child.getPositionForOffset(offset - parentData.offset); + return TextPosition( + offset: localPosition.offset + child.container.offset, + affinity: localPosition.affinity, + ); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final child = childAtPosition(position); + final nodeOffset = child.container.offset; + final childWord = child + .getWordBoundary(TextPosition(offset: position.offset - nodeOffset)); + return TextRange( + start: childWord.start + nodeOffset, + end: childWord.end + nodeOffset, + ); + } + + @override + TextPosition? getPositionAbove(TextPosition position) { + assert(position.offset < container.length); + + final child = childAtPosition(position); + final childLocalPosition = + TextPosition(offset: position.offset - child.container.offset); + final result = child.getPositionAbove(childLocalPosition); + if (result != null) { + return TextPosition(offset: result.offset + child.container.offset); + } + + final sibling = childBefore(child); + if (sibling == null) { + return null; + } + + final caretOffset = child.getOffsetForCaret(childLocalPosition); + final testPosition = TextPosition(offset: sibling.container.length - 1); + final testOffset = sibling.getOffsetForCaret(testPosition); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + return TextPosition( + offset: sibling.container.offset + + sibling.getPositionForOffset(finalOffset).offset); + } + + @override + TextPosition? getPositionBelow(TextPosition position) { + assert(position.offset < container.length); + + final child = childAtPosition(position); + final childLocalPosition = + TextPosition(offset: position.offset - child.container.offset); + final result = child.getPositionBelow(childLocalPosition); + if (result != null) { + return TextPosition(offset: result.offset + child.container.offset); + } + + final sibling = childAfter(child); + if (sibling == null) { + return null; + } + + final caretOffset = child.getOffsetForCaret(childLocalPosition); + final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0)); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + return TextPosition( + offset: sibling.container.offset + + sibling.getPositionForOffset(finalOffset).offset); + } + + @override + double preferredLineHeight(TextPosition position) { + final child = childAtPosition(position); + return child.preferredLineHeight( + TextPosition(offset: position.offset - child.container.offset)); + } + + @override + TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) { + if (selection.isCollapsed) { + return TextSelectionPoint( + Offset(0, preferredLineHeight(selection.extent)) + + getOffsetForCaret(selection.extent), + null, + ); + } + + final baseNode = container + .queryChild( + selection.start, + false, + ) + .node; + var baseChild = firstChild; + while (baseChild != null) { + if (baseChild.container == baseNode) { + break; + } + baseChild = childAfter(baseChild); + } + assert(baseChild != null); + + final basePoint = baseChild!.getBaseEndpointForSelection( + localSelection( + baseChild.container, + selection, + true, + ), + ); + return TextSelectionPoint( + basePoint.point + (baseChild.parentData as BoxParentData).offset, + basePoint.direction, + ); + } + + @override + TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) { + if (selection.isCollapsed) { + return TextSelectionPoint( + Offset(0, preferredLineHeight(selection.extent)) + + getOffsetForCaret(selection.extent), + null, + ); + } + + final extentNode = container.queryChild(selection.end, false).node; + + var extentChild = firstChild; + while (extentChild != null) { + if (extentChild.container == extentNode) { + break; + } + extentChild = childAfter(extentChild); + } + assert(extentChild != null); + + final extentPoint = extentChild!.getExtentEndpointForSelection( + localSelection( + extentChild.container, + selection, + true, + ), + ); + return TextSelectionPoint( + extentPoint.point + (extentChild.parentData as BoxParentData).offset, + extentPoint.direction, + ); + } + + @override + void detach() { + _painter?.dispose(); + _painter = null; + super.detach(); + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _paintDecoration(context, offset); + defaultPaint(context, offset); + } + + void _paintDecoration(PaintingContext context, Offset offset) { + _painter ??= _decoration.createBoxPainter(markNeedsPaint); + + final decorationPadding = resolvedPadding! - _contentPadding; + + final filledConfiguration = + configuration.copyWith(size: decorationPadding.deflateSize(size)); + final debugSaveCount = context.canvas.getSaveCount(); + + final decorationOffset = + offset.translate(decorationPadding.left, decorationPadding.top); + _painter!.paint(context.canvas, decorationOffset, filledConfiguration); + if (debugSaveCount != context.canvas.getSaveCount()) { + throw StateError( + '${_decoration.runtimeType} painter had mismatching save and ' + 'restore calls.', + ); + } + if (decoration.isComplex) { + context.setIsComplexHint(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + Rect getLocalRectForCaret(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.container.offset, + affinity: position.affinity, + ); + final parentData = child.parentData as BoxParentData; + return child.getLocalRectForCaret(localPosition).shift(parentData.offset); + } + + @override + TextPosition globalToLocalPosition(TextPosition position) { + assert(container.containsOffset(position.offset) || container.length == 0, + 'The provided text position is not in the current node'); + return TextPosition( + offset: position.offset - container.documentOffset, + affinity: position.affinity, + ); + } + + @override + Rect getCaretPrototype(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.container.offset, + affinity: position.affinity, + ); + return child.getCaretPrototype(localPosition); + } +} diff --git a/lib/src/editor/widgets/text/text_block.dart b/lib/src/editor/widgets/text/block_line/text_block.dart similarity index 50% rename from lib/src/editor/widgets/text/text_block.dart rename to lib/src/editor/widgets/text/block_line/text_block.dart index 9f619b634..93a59ca88 100644 --- a/lib/src/editor/widgets/text/text_block.dart +++ b/lib/src/editor/widgets/text/block_line/text_block.dart @@ -1,61 +1,55 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -import '../../../common/structs/horizontal_spacing.dart'; -import '../../../common/structs/vertical_spacing.dart'; -import '../../../common/utils/font.dart'; -import '../../../controller/quill_controller.dart'; -import '../../../delta/delta_diff.dart'; -import '../../../document/attribute.dart'; -import '../../../document/nodes/block.dart'; -import '../../../document/nodes/line.dart'; -import '../../../toolbar/base_toolbar.dart'; -import '../../editor.dart'; -import '../../embed/embed_editor_builder.dart'; -import '../../provider.dart'; -import '../../raw_editor/builders/leading_block_builder.dart'; -import '../box.dart'; -import '../cursor.dart'; -import '../default_leading_components/leading_components.dart'; -import '../default_styles.dart'; -import '../delegate.dart'; -import '../link.dart'; -import 'text_line.dart'; -import 'text_selection.dart'; -import 'utils/text_block_utils.dart'; - -const List arabianRomanNumbers = [ - 1000, - 900, - 500, - 400, - 100, - 90, - 50, - 40, - 10, - 9, - 5, - 4, - 1 -]; - -const List romanNumbers = [ - 'M', - 'CM', - 'D', - 'CD', - 'C', - 'XC', - 'L', - 'XL', - 'X', - 'IX', - 'V', - 'IV', - 'I' -]; - +import 'package:flutter/material.dart' + show + Border, + BorderSide, + BoxDecoration, + BuildContext, + Color, + Colors, + Directionality, + EdgeInsets, + FontWeight, + MediaQuery, + StatelessWidget, + TextDirection, + TextRange, + TextSelection, + ValueChanged, + Widget, + debugCheckHasMediaQuery, + immutable; +import 'package:meta/meta.dart' show internal; +import '../../../../common/structs/horizontal_spacing.dart' + show HorizontalSpacing; +import '../../../../common/structs/vertical_spacing.dart' show VerticalSpacing; +import '../../../../common/utils/font.dart' show getFontSizeAsDouble; +import '../../../../controller/quill_controller.dart' show QuillController; +import '../../../../delta/delta_diff.dart' show getDirectionOfNode; +import '../../../../document/attribute.dart' show Attribute; +import '../../../../document/nodes/block.dart' show Block; +import '../../../../document/nodes/line.dart' show Line; +import '../../../../toolbar/base_toolbar.dart' show hexToColor; +import '../../../embed/embed_editor_builder.dart' show EmbedsBuilder; +import '../../../provider.dart' show QuillEditorExt; +import '../../../raw_editor/builders/leading_block_builder.dart' + show LeadingBlockNodeBuilder, LeadingConfigurations; +import '../../cursor.dart' show CursorCont; +import '../../default_leading_components/leading_components.dart' + show + bulletPointLeading, + checkboxLeading, + codeBlockLineNumberLeading, + numberPointLeading; +import '../../default_styles.dart' show DefaultStyles, QuillStyles; +import '../../delegate.dart' show CustomRecognizerBuilder, CustomStyleBuilder; +import '../../link.dart' show LinkActionPicker; +import '../line/editable_text_line.dart' show EditableTextLine; +import '../line/text_line.dart' show TextLine; +import 'editable_text_block.dart' show EditableBlock; +import 'utils/text_block_utils.dart' show TextBlockUtils; + +@internal +@immutable class EditableTextBlock extends StatelessWidget { const EditableTextBlock({ required this.block, @@ -119,7 +113,7 @@ class EditableTextBlock extends StatelessWidget { assert(debugCheckHasMediaQuery(context)); final defaultStyles = QuillStyles.getStyles(context, false); - return _EditableBlock( + return EditableBlock( block: block, textDirection: textDirection, horizontalSpacing: horizontalSpacing, @@ -425,362 +419,3 @@ class EditableTextBlock extends StatelessWidget { return VerticalSpacing(top, bottom); } } - -class RenderEditableTextBlock extends RenderEditableContainerBox - implements RenderEditableBox { - RenderEditableTextBlock({ - required Block block, - required super.textDirection, - required EdgeInsetsGeometry padding, - required super.scrollBottomInset, - required Decoration decoration, - super.children, - EdgeInsets contentPadding = EdgeInsets.zero, - }) : _decoration = decoration, - _configuration = ImageConfiguration(textDirection: textDirection), - _savedPadding = padding, - _contentPadding = contentPadding, - super( - container: block, - padding: padding.add(contentPadding), - ); - - EdgeInsetsGeometry _savedPadding; - EdgeInsets _contentPadding; - - set contentPadding(EdgeInsets value) { - if (_contentPadding == value) return; - _contentPadding = value; - super.setPadding(_savedPadding.add(_contentPadding)); - } - - @override - void setPadding(EdgeInsetsGeometry value) { - super.setPadding(value.add(_contentPadding)); - _savedPadding = value; - } - - BoxPainter? _painter; - - Decoration get decoration => _decoration; - Decoration _decoration; - - set decoration(Decoration value) { - if (value == _decoration) return; - _painter?.dispose(); - _painter = null; - _decoration = value; - markNeedsPaint(); - } - - ImageConfiguration get configuration => _configuration; - ImageConfiguration _configuration; - - set configuration(ImageConfiguration value) { - if (value == _configuration) return; - _configuration = value; - markNeedsPaint(); - } - - @override - TextRange getLineBoundary(TextPosition position) { - final child = childAtPosition(position); - final rangeInChild = child.getLineBoundary(TextPosition( - offset: position.offset - child.container.offset, - affinity: position.affinity, - )); - return TextRange( - start: rangeInChild.start + child.container.offset, - end: rangeInChild.end + child.container.offset, - ); - } - - @override - Offset getOffsetForCaret(TextPosition position) { - final child = childAtPosition(position); - return child.getOffsetForCaret(TextPosition( - offset: position.offset - child.container.offset, - affinity: position.affinity, - )) + - (child.parentData as BoxParentData).offset; - } - - @override - TextPosition getPositionForOffset(Offset offset) { - final child = childAtOffset(offset); - final parentData = child.parentData as BoxParentData; - final localPosition = - child.getPositionForOffset(offset - parentData.offset); - return TextPosition( - offset: localPosition.offset + child.container.offset, - affinity: localPosition.affinity, - ); - } - - @override - TextRange getWordBoundary(TextPosition position) { - final child = childAtPosition(position); - final nodeOffset = child.container.offset; - final childWord = child - .getWordBoundary(TextPosition(offset: position.offset - nodeOffset)); - return TextRange( - start: childWord.start + nodeOffset, - end: childWord.end + nodeOffset, - ); - } - - @override - TextPosition? getPositionAbove(TextPosition position) { - assert(position.offset < container.length); - - final child = childAtPosition(position); - final childLocalPosition = - TextPosition(offset: position.offset - child.container.offset); - final result = child.getPositionAbove(childLocalPosition); - if (result != null) { - return TextPosition(offset: result.offset + child.container.offset); - } - - final sibling = childBefore(child); - if (sibling == null) { - return null; - } - - final caretOffset = child.getOffsetForCaret(childLocalPosition); - final testPosition = TextPosition(offset: sibling.container.length - 1); - final testOffset = sibling.getOffsetForCaret(testPosition); - final finalOffset = Offset(caretOffset.dx, testOffset.dy); - return TextPosition( - offset: sibling.container.offset + - sibling.getPositionForOffset(finalOffset).offset); - } - - @override - TextPosition? getPositionBelow(TextPosition position) { - assert(position.offset < container.length); - - final child = childAtPosition(position); - final childLocalPosition = - TextPosition(offset: position.offset - child.container.offset); - final result = child.getPositionBelow(childLocalPosition); - if (result != null) { - return TextPosition(offset: result.offset + child.container.offset); - } - - final sibling = childAfter(child); - if (sibling == null) { - return null; - } - - final caretOffset = child.getOffsetForCaret(childLocalPosition); - final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0)); - final finalOffset = Offset(caretOffset.dx, testOffset.dy); - return TextPosition( - offset: sibling.container.offset + - sibling.getPositionForOffset(finalOffset).offset); - } - - @override - double preferredLineHeight(TextPosition position) { - final child = childAtPosition(position); - return child.preferredLineHeight( - TextPosition(offset: position.offset - child.container.offset)); - } - - @override - TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) { - if (selection.isCollapsed) { - return TextSelectionPoint( - Offset(0, preferredLineHeight(selection.extent)) + - getOffsetForCaret(selection.extent), - null, - ); - } - - final baseNode = container - .queryChild( - selection.start, - false, - ) - .node; - var baseChild = firstChild; - while (baseChild != null) { - if (baseChild.container == baseNode) { - break; - } - baseChild = childAfter(baseChild); - } - assert(baseChild != null); - - final basePoint = baseChild!.getBaseEndpointForSelection( - localSelection( - baseChild.container, - selection, - true, - ), - ); - return TextSelectionPoint( - basePoint.point + (baseChild.parentData as BoxParentData).offset, - basePoint.direction, - ); - } - - @override - TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) { - if (selection.isCollapsed) { - return TextSelectionPoint( - Offset(0, preferredLineHeight(selection.extent)) + - getOffsetForCaret(selection.extent), - null, - ); - } - - final extentNode = container.queryChild(selection.end, false).node; - - var extentChild = firstChild; - while (extentChild != null) { - if (extentChild.container == extentNode) { - break; - } - extentChild = childAfter(extentChild); - } - assert(extentChild != null); - - final extentPoint = extentChild!.getExtentEndpointForSelection( - localSelection( - extentChild.container, - selection, - true, - ), - ); - return TextSelectionPoint( - extentPoint.point + (extentChild.parentData as BoxParentData).offset, - extentPoint.direction, - ); - } - - @override - void detach() { - _painter?.dispose(); - _painter = null; - super.detach(); - markNeedsPaint(); - } - - @override - void paint(PaintingContext context, Offset offset) { - _paintDecoration(context, offset); - defaultPaint(context, offset); - } - - void _paintDecoration(PaintingContext context, Offset offset) { - _painter ??= _decoration.createBoxPainter(markNeedsPaint); - - final decorationPadding = resolvedPadding! - _contentPadding; - - final filledConfiguration = - configuration.copyWith(size: decorationPadding.deflateSize(size)); - final debugSaveCount = context.canvas.getSaveCount(); - - final decorationOffset = - offset.translate(decorationPadding.left, decorationPadding.top); - _painter!.paint(context.canvas, decorationOffset, filledConfiguration); - if (debugSaveCount != context.canvas.getSaveCount()) { - throw StateError( - '${_decoration.runtimeType} painter had mismatching save and ' - 'restore calls.', - ); - } - if (decoration.isComplex) { - context.setIsComplexHint(); - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } - - @override - Rect getLocalRectForCaret(TextPosition position) { - final child = childAtPosition(position); - final localPosition = TextPosition( - offset: position.offset - child.container.offset, - affinity: position.affinity, - ); - final parentData = child.parentData as BoxParentData; - return child.getLocalRectForCaret(localPosition).shift(parentData.offset); - } - - @override - TextPosition globalToLocalPosition(TextPosition position) { - assert(container.containsOffset(position.offset) || container.length == 0, - 'The provided text position is not in the current node'); - return TextPosition( - offset: position.offset - container.documentOffset, - affinity: position.affinity, - ); - } - - @override - Rect getCaretPrototype(TextPosition position) { - final child = childAtPosition(position); - final localPosition = TextPosition( - offset: position.offset - child.container.offset, - affinity: position.affinity, - ); - return child.getCaretPrototype(localPosition); - } -} - -class _EditableBlock extends MultiChildRenderObjectWidget { - const _EditableBlock( - {required this.block, - required this.textDirection, - required this.horizontalSpacing, - required this.verticalSpacing, - required this.scrollBottomInset, - required this.decoration, - required this.contentPadding, - required super.children}); - - final Block block; - final TextDirection textDirection; - final HorizontalSpacing horizontalSpacing; - final VerticalSpacing verticalSpacing; - final double scrollBottomInset; - final Decoration decoration; - final EdgeInsets? contentPadding; - - EdgeInsets get _padding => EdgeInsets.only( - left: horizontalSpacing.left, - right: horizontalSpacing.right, - top: verticalSpacing.top, - bottom: verticalSpacing.bottom); - - EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; - - @override - RenderEditableTextBlock createRenderObject(BuildContext context) { - return RenderEditableTextBlock( - block: block, - textDirection: textDirection, - padding: _padding, - scrollBottomInset: scrollBottomInset, - decoration: decoration, - contentPadding: _contentPadding, - ); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderEditableTextBlock renderObject) { - renderObject - ..setContainer(block) - ..textDirection = textDirection - ..scrollBottomInset = scrollBottomInset - ..setPadding(_padding) - ..decoration = decoration - ..contentPadding = _contentPadding; - } -} diff --git a/lib/src/editor/widgets/text/utils/text_block_utils.dart b/lib/src/editor/widgets/text/block_line/utils/text_block_utils.dart similarity index 90% rename from lib/src/editor/widgets/text/utils/text_block_utils.dart rename to lib/src/editor/widgets/text/block_line/utils/text_block_utils.dart index 9a1b5c552..a84640d1c 100644 --- a/lib/src/editor/widgets/text/utils/text_block_utils.dart +++ b/lib/src/editor/widgets/text/block_line/utils/text_block_utils.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import '../../../../common/structs/horizontal_spacing.dart'; -import '../../../../document/attribute.dart'; -import '../../../../document/nodes/block.dart'; -import '../../default_styles.dart'; +import '../../../../../common/structs/horizontal_spacing.dart'; +import '../../../../../document/attribute.dart'; +import '../../../../../document/nodes/block.dart'; +import '../../../default_styles.dart'; typedef LeadingBlockIndentWidth = HorizontalSpacing Function( Block block, diff --git a/lib/src/editor/widgets/text/line/editable_text_line.dart b/lib/src/editor/widgets/text/line/editable_text_line.dart new file mode 100644 index 000000000..a58de728b --- /dev/null +++ b/lib/src/editor/widgets/text/line/editable_text_line.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import '../../../../common/structs/horizontal_spacing.dart'; +import '../../../../common/structs/vertical_spacing.dart'; +import '../../../../document/nodes/line.dart'; +import '../../cursor.dart'; +import '../../default_styles.dart'; +import 'render_editable_text_line.dart'; +import 'render_text_line_element.dart'; + +//TODO: we need to document this render object +@internal +class EditableTextLine extends RenderObjectWidget { + const EditableTextLine( + this.line, + this.leading, + this.body, + this.horizontalSpacing, + this.verticalSpacing, + this.textDirection, + this.textSelection, + this.color, + this.enableInteractiveSelection, + this.hasFocus, + this.devicePixelRatio, + this.cursorCont, + this.inlineCodeStyle, + {super.key}); + + final Line line; + final Widget? leading; + final Widget body; + final HorizontalSpacing horizontalSpacing; + final VerticalSpacing verticalSpacing; + final TextDirection textDirection; + final TextSelection textSelection; + final Color color; + final bool enableInteractiveSelection; + final bool hasFocus; + final double devicePixelRatio; + final CursorCont cursorCont; + final InlineCodeStyle inlineCodeStyle; + + @override + RenderObjectElement createElement() { + return TextLineElement(this); + } + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderEditableTextLine( + line, + textDirection, + textSelection, + enableInteractiveSelection, + hasFocus, + devicePixelRatio, + _getPadding(), + color, + cursorCont, + inlineCodeStyle); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderEditableTextLine renderObject) { + renderObject + ..setLine(line) + ..setPadding(_getPadding()) + ..setTextDirection(textDirection) + ..setTextSelection(textSelection) + ..setColor(color) + ..setEnableInteractiveSelection(enableInteractiveSelection) + ..hasFocus = hasFocus + ..setDevicePixelRatio(devicePixelRatio) + ..setCursorCont(cursorCont) + ..setInlineCodeStyle(inlineCodeStyle); + } + + EdgeInsetsGeometry _getPadding() { + return EdgeInsetsDirectional.only( + start: horizontalSpacing.left, + end: horizontalSpacing.right, + top: verticalSpacing.top, + bottom: verticalSpacing.bottom); + } +} diff --git a/lib/src/editor/widgets/text/line/render_editable_text_line.dart b/lib/src/editor/widgets/text/line/render_editable_text_line.dart new file mode 100644 index 000000000..58beb798a --- /dev/null +++ b/lib/src/editor/widgets/text/line/render_editable_text_line.dart @@ -0,0 +1,756 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart' + show + Color, + DiagnosticsNode, + EdgeInsets, + EdgeInsetsGeometry, + Offset, + Paint, + PaintingContext, + RRect, + Rect, + RenderBox, + Size, + TextBaseline, + TextBox, + TextDirection, + TextPosition, + TextRange, + TextSelection, + TextSelectionPoint; +import 'package:flutter/rendering.dart' + show BoxParentData, PipelineOwner, BoxHitTestResult, RenderObjectVisitor; +import 'package:meta/meta.dart'; +import '../../../../../flutter_quill_internal.dart' as leaf; +import '../../../../common/utils/platform.dart'; +import '../../../../document/attribute.dart'; +import '../../../../document/nodes/container.dart' as container_node; +import '../../../../document/nodes/line.dart'; +import '../../box.dart'; +import '../../cursor.dart'; +import '../../default_styles.dart'; +import '../selection/text_selection.dart'; +import 'text_line.dart' show TextLineSlot; + +@internal +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? _selectedRects; + late Rect _caretPrototype; + InlineCodeStyle inlineCodeStyle; + final Map children = {}; + + Iterable 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 _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); + if (lineBoxes.isEmpty) { + // Empty line, line box is empty + return TextRange.collapsed(position.offset); + } + 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) { + double? maxOffset; + double limit() => maxOffset ??= + _body!.semanticBounds.height / preferredLineHeight(position) + 1; + bool checkLimit(double offset) => offset < 4.0 ? false : offset > limit(); + + /// Move up by fraction of the default font height, larger font sizes need larger offset, embed images need larger offset + for (var offset = 0.5;; offset += offset < 4 ? 0.25 : 1.0) { + final pos = _getPosition(position, -offset); + if (pos?.offset != position.offset || checkLimit(offset)) { + return pos; + } + } + } + + @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.QuillContainer 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 (isIos) { + _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 debugDescribeChildren() { + final value = []; + 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) { + if (textDirection == TextDirection.ltr) { + final parentData = _leading!.parentData as BoxParentData; + final effectiveOffset = offset + parentData.offset; + context.paintChild(_leading!, effectiveOffset); + } else { + final parentData = _leading!.parentData as BoxParentData; + final effectiveOffset = offset + parentData.offset; + context.paintChild( + _leading!, + Offset( + size.width - _leading!.size.width, + effectiveOffset.dy, + ), + ); + } + } + + 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.QuillText || + !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; +} diff --git a/lib/src/editor/widgets/text/line/render_text_line_element.dart b/lib/src/editor/widgets/text/line/render_text_line_element.dart new file mode 100644 index 000000000..ae1c06fdc --- /dev/null +++ b/lib/src/editor/widgets/text/line/render_text_line_element.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart' + show + Element, + ElementVisitor, + RenderBox, + RenderObject, + RenderObjectElement, + Widget; +import 'package:meta/meta.dart' show internal; +import '../../box.dart' show RenderContentProxyBox; +import 'editable_text_line.dart' show EditableTextLine; +import 'render_editable_text_line.dart' show RenderEditableTextLine; +import 'text_line.dart' show TextLineSlot; + +//TODO: we need to document what does this element +// and why we use it +@internal +class TextLineElement extends RenderObjectElement { + TextLineElement(EditableTextLine super.line); + + final Map _slotToChildren = {}; + + @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; + } + } +} diff --git a/lib/src/editor/widgets/text/line/text_line.dart b/lib/src/editor/widgets/text/line/text_line.dart new file mode 100644 index 000000000..c325e30bf --- /dev/null +++ b/lib/src/editor/widgets/text/line/text_line.dart @@ -0,0 +1,692 @@ +import 'dart:collection'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/gestures.dart' + show GestureRecognizer, LongPressGestureRecognizer, TapGestureRecognizer; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show ClipboardData, Clipboard; +import 'package:meta/meta.dart'; +import 'package:url_launcher/url_launcher_string.dart' show launchUrlString; +import '../../../../common/utils/color.dart'; +import '../../../../common/utils/font.dart'; +import '../../../../common/utils/platform.dart'; +import '../../../../controller/quill_controller.dart'; +import '../../../../document/attribute.dart'; +import '../../../../document/nodes/leaf.dart' as leaf; +import '../../../../document/nodes/line.dart'; +import '../../../../document/nodes/node.dart'; +import '../../../../document/style.dart'; +import '../../../embed/embed_editor_builder.dart'; +import '../../../spellchecker/spellchecker_service_provider.dart'; +import '../../default_styles.dart'; +import '../../delegate.dart'; +import '../../keyboard_listener.dart'; +import '../../link.dart'; +import '../../proxy.dart'; + +@internal +enum TextLineSlot { leading, body } + +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, + required this.composingRange, + this.textDirection, + this.customStyleBuilder, + this.customRecognizerBuilder, + this.customLinkPrefixes = const [], + super.key, + }); + + final Line line; + final TextDirection? textDirection; + final EmbedsBuilder embedBuilder; + final DefaultStyles styles; + final bool readOnly; + final QuillController controller; + final CustomStyleBuilder? customStyleBuilder; + final CustomRecognizerBuilder? customRecognizerBuilder; + final ValueChanged? onLaunchUrl; + final LinkActionPicker linkActionPicker; + final List customLinkPrefixes; + final TextRange composingRange; + + @override + State createState() => _TextLineState(); +} + +class _TextLineState extends State { + bool _metaOrControlPressed = false; + + UniqueKey _richTextKey = UniqueKey(); + + final _linkRecognizers = {}; + + QuillPressedKeys? _pressedKeys; + + void _pressedKeysChanged() { + final newValue = _pressedKeys!.metaPressed || _pressedKeys!.controlPressed; + if (_metaOrControlPressed != newValue) { + setState(() { + _metaOrControlPressed = newValue; + _linkRecognizers + ..forEach((key, value) { + value.dispose(); + }) + ..clear(); + }); + } + } + + 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 (isDesktopApp) { + 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(); + } + + /// Check if this line contains the placeholder attribute + bool get isPlaceholderLine => + widget.line.toDelta().first.attributes?.containsKey('placeholder') ?? + false; + + @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 leaf.Embed; + // Creates correct node for custom embed + if (embed.value.type == leaf.BlockEmbed.customType) { + embed = leaf.Embed( + leaf.CustomBlockEmbed.fromJsonString(embed.value.data), + ); + } + final embedBuilder = widget.embedBuilder(embed); + if (embedBuilder.expanded) { + // Creates correct node for custom embed + final lineStyle = _getLineStyle(widget.styles); + return EmbedProxy( + embedBuilder.build( + context, + widget.controller, + embed, + widget.readOnly, + false, + lineStyle, + ), + ); + } + } + final textSpan = _getTextSpanForWholeLine(); + final strutStyle = + StrutStyle.fromTextStyle(textSpan.style ?? const TextStyle()); + final textAlign = _getTextAlign(); + final child = RichText( + key: _richTextKey, + text: textSpan, + textAlign: textAlign, + textDirection: widget.textDirection, + strutStyle: strutStyle, + textScaler: MediaQuery.textScalerOf(context), + ); + return RichTextProxy( + textStyle: textSpan.style ?? const TextStyle(), + textAlign: textAlign, + textDirection: widget.textDirection!, + strutStyle: strutStyle, + locale: Localizations.localeOf(context), + textScaler: MediaQuery.textScalerOf(context), + child: child, + ); + } + + InlineSpan _getTextSpanForWholeLine() { + var 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 = []; + var textNodes = LinkedList(); + for (var child in widget.line.children) { + if (child is leaf.Embed) { + if (textNodes.isNotEmpty) { + textSpanChildren + .add(_buildTextSpan(widget.styles, textNodes, lineStyle)); + textNodes = LinkedList(); + } + // Creates correct node for custom embed + if (child.value.type == leaf.BlockEmbed.customType) { + child = + leaf.Embed(leaf.CustomBlockEmbed.fromJsonString(child.value.data)) + ..applyStyle(child.style); + } + + if (child.value.type == leaf.BlockEmbed.formulaType) { + lineStyle = lineStyle.merge(_getInlineTextStyle( + child.style, + widget.styles, + widget.line.style, + false, + )); + } + + final embedBuilder = widget.embedBuilder(child); + final embedWidget = EmbedProxy( + embedBuilder.build( + context, + widget.controller, + child, + widget.readOnly, + true, + lineStyle, + ), + ); + 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 nodes, + TextStyle lineStyle, + ) { + if (nodes.isEmpty && kIsWeb) { + nodes = LinkedList()..add(leaf.QuillText('\u{200B}')); + } + + final isComposingRangeOutOfLine = !widget.composingRange.isValid || + widget.composingRange.isCollapsed || + (widget.composingRange.start < widget.line.documentOffset || + widget.composingRange.end > + widget.line.documentOffset + widget.line.length); + + if (isComposingRangeOutOfLine) { + final children = nodes + .map((node) => + _getTextSpanFromNode(defaultStyles, node, widget.line.style)) + .toList(growable: false); + return TextSpan(children: children, style: lineStyle); + } + + final children = nodes.expand((node) { + final child = + _getTextSpanFromNode(defaultStyles, node, widget.line.style); + final isNodeInComposingRange = + node.documentOffset <= widget.composingRange.start && + widget.composingRange.end <= node.documentOffset + node.length; + if (isNodeInComposingRange) { + return _splitAndApplyComposingStyle(node, child); + } else { + return [child]; + } + }).toList(growable: false); + + return TextSpan(children: children, style: lineStyle); + } + + // split the text nodes into composing and non-composing nodes + // and apply the composing style to the composing nodes + List _splitAndApplyComposingStyle(Node node, InlineSpan child) { + assert(widget.composingRange.isValid && !widget.composingRange.isCollapsed); + + final composingStart = widget.composingRange.start - node.documentOffset; + final composingEnd = widget.composingRange.end - node.documentOffset; + final text = child.toPlainText(); + + final textBefore = text.substring(0, composingStart); + final textComposing = text.substring(composingStart, composingEnd); + final textAfter = text.substring(composingEnd); + + final composingStyle = child.style + ?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? + const TextStyle(decoration: TextDecoration.underline); + + return [ + TextSpan( + text: textBefore, + style: child.style, + ), + TextSpan( + text: textComposing, + style: composingStyle, + ), + TextSpan( + text: textAfter, + style: child.style, + ), + ]; + } + + 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.h1: defaultStyles.h1!.style, + Attribute.h2: defaultStyles.h2!.style, + Attribute.h3: defaultStyles.h3!.style, + Attribute.h4: defaultStyles.h4!.style, + Attribute.h5: defaultStyles.h5!.style, + Attribute.h6: defaultStyles.h6!.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?.key == Attribute.list.key) { + toMerge = defaultStyles.lists!.style; + } + + textStyle = textStyle.merge(toMerge); + + final lineHeight = widget.line.style.attributes[Attribute.lineHeight.key]; + final x = { + LineHeightAttribute.lineHeightNormal: + defaultStyles.lineHeightNormal!.style, + LineHeightAttribute.lineHeightTight: defaultStyles.lineHeightTight!.style, + LineHeightAttribute.lineHeightOneAndHalf: + defaultStyles.lineHeightOneAndHalf!.style, + LineHeightAttribute.lineHeightDouble: + defaultStyles.lineHeightDouble!.style, + }; + + // If the lineHeight attribute isn't null, then get just the height param instead whole TextStyle + // to avoid modify the current style of the text line + textStyle = + textStyle.merge(textStyle.copyWith(height: x[lineHeight]?.height)); + + textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes); + + if (isPlaceholderLine) { + final oldStyle = textStyle; + textStyle = defaultStyles.placeHolder!.style; + textStyle = textStyle.merge(oldStyle.copyWith( + color: textStyle.color, + backgroundColor: textStyle.backgroundColor, + background: textStyle.background, + )); + } + + return textStyle; + } + + TextStyle _applyCustomAttributes( + TextStyle textStyle, Map attributes) { + if (widget.customStyleBuilder == null) { + return textStyle; + } + for (final key in attributes.keys) { + final attr = attributes[key]; + if (attr != null) { + /// Custom Attribute + final customAttr = widget.customStyleBuilder!.call(attr); + textStyle = textStyle.merge(customAttr); + } + } + return textStyle; + } + + /// Processes subscript and superscript attributed text. + /// + /// Reduces text fontSize and shifts down or up. Increases fontWeight to maintain balance with normal text. + /// Outputs characters individually to allow correct caret positioning and text selection. + InlineSpan _scriptSpan(String text, bool superScript, TextStyle style, + DefaultStyles defaultStyles) { + assert(text.isNotEmpty); + // + final lineStyle = style.fontSize == null || style.fontWeight == null + ? _getLineStyle(defaultStyles) + : null; + final fontWeight = FontWeight.lerp( + style.fontWeight ?? lineStyle?.fontWeight ?? FontWeight.normal, + FontWeight.w900, + 0.25); + final fontSize = style.fontSize ?? lineStyle?.fontSize ?? 16; + final y = (superScript ? -0.4 : 0.14) * fontSize; + final charStyle = style.copyWith( + fontFeatures: [], + fontWeight: fontWeight, + fontSize: fontSize * 0.7); + // + final offset = Offset(0, y); + final children = []; + for (final c in text.characters) { + children.add(WidgetSpan( + child: Transform.translate( + offset: offset, + child: Text( + c, + style: charStyle, + )))); + } + // + if (children.length > 1) { + return TextSpan(children: children); + } + return children[0]; + } + + InlineSpan _getTextSpanFromNode( + DefaultStyles defaultStyles, Node node, Style lineStyle) { + final textNode = node as leaf.QuillText; + final nodeStyle = textNode.style; + final isLink = nodeStyle.containsKey(Attribute.link.key) && + nodeStyle.attributes[Attribute.link.key]!.value != null; + final style = + _getInlineTextStyle(nodeStyle, defaultStyles, lineStyle, isLink); + if (widget.controller.configurations.requireScriptFontFeatures == false && + textNode.value.isNotEmpty) { + if (nodeStyle.containsKey(Attribute.script.key)) { + final attr = nodeStyle.attributes[Attribute.script.key]; + if (attr == Attribute.superscript || attr == Attribute.subscript) { + return _scriptSpan(textNode.value, attr == Attribute.superscript, + style, defaultStyles); + } + } + } + + if (!isLink && + !widget.readOnly && + !widget.line.style.attributes.containsKey('code-block') && + !widget.line.style.attributes.containsKey('placeholder') && + !isPlaceholderLine) { + // ignore: deprecated_member_use_from_same_package + final service = SpellCheckerServiceProvider.instance; + final spellcheckedSpans = service.checkSpelling(textNode.value); + if (spellcheckedSpans != null && spellcheckedSpans.isNotEmpty) { + return TextSpan( + children: spellcheckedSpans, + style: style, + mouseCursor: null, + ); + } + } + + final recognizer = _getRecognizer(node, isLink); + return TextSpan( + text: textNode.value, + style: style, + recognizer: recognizer, + mouseCursor: (recognizer != null) ? SystemMouseCursors.click : null, + ); + } + + TextStyle _getInlineTextStyle(Style nodeStyle, DefaultStyles defaultStyles, + Style lineStyle, bool isLink) { + var res = const TextStyle(); // This is inline text style + final color = nodeStyle.attributes[Attribute.color.key]; + + { + 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, defaultStyles); + } + 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.script.key)) { + if (nodeStyle.attributes.values.contains(Attribute.subscript)) { + res = _merge(res, defaultStyles.subscript!); + } else if (nodeStyle.attributes.values.contains(Attribute.superscript)) { + res = _merge(res, defaultStyles.superscript!); + } + } + + if (nodeStyle.containsKey(Attribute.inlineCode.key)) { + res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle)); + } + + final font = nodeStyle.attributes[Attribute.font.key]; + if (font != null && font.value != null) { + res = res.merge(TextStyle(fontFamily: font.value)); + } + + final size = nodeStyle.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, null, defaultStyles); + } + if (textColor != null) { + res = res.merge(TextStyle(color: textColor)); + } + } + + final background = nodeStyle.attributes[Attribute.background.key]; + if (background != null && background.value != null) { + final backgroundColor = + stringToColor(background.value, null, defaultStyles); + res = res.merge(TextStyle(backgroundColor: backgroundColor)); + } + + res = _applyCustomAttributes(res, nodeStyle.attributes); + return res; + } + + GestureRecognizer? _getRecognizer(Node segment, bool isLink) { + if (_linkRecognizers.containsKey(segment)) { + return _linkRecognizers[segment]!; + } + + if (widget.customRecognizerBuilder != null) { + final textNode = segment as leaf.QuillText; + final nodeStyle = textNode.style; + + nodeStyle.attributes.forEach((key, value) { + final recognizer = widget.customRecognizerBuilder!.call(value, segment); + if (recognizer != null) { + _linkRecognizers[segment] = recognizer; + return; + } + }); + } + + if (_linkRecognizers.containsKey(segment)) { + return _linkRecognizers[segment]!; + } + + if (isLink && canLaunchLinks) { + if (isDesktop || widget.readOnly) { + _linkRecognizers[segment] = TapGestureRecognizer() + ..onTap = () => _tapNodeLink(segment); + } else { + _linkRecognizers[segment] = LongPressGestureRecognizer() + ..onLongPress = () => _longPressLink(segment); + } + } + return _linkRecognizers[segment]; + } + + Future _launchUrl(String url) async { + await launchUrlString(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 (!(widget.customLinkPrefixes + linkPrefixes) + .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { + link = 'https://$link'; + } + launchUrl(link); + } + + Future _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: + 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 = []; + 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(decorations))); + } +} diff --git a/lib/src/editor/widgets/text/selection/drag_text_selection.dart b/lib/src/editor/widgets/text/selection/drag_text_selection.dart new file mode 100644 index 000000000..7360a1224 --- /dev/null +++ b/lib/src/editor/widgets/text/selection/drag_text_selection.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +/// internal use, used to get drag direction information +@internal +class DragTextSelection extends TextSelection { + const DragTextSelection({ + super.affinity, + super.baseOffset = 0, + super.extentOffset = 0, + super.isDirectional, + this.first = true, + }); + + final bool first; + + @override + DragTextSelection copyWith({ + int? baseOffset, + int? extentOffset, + TextAffinity? affinity, + bool? isDirectional, + bool? first, + }) { + return DragTextSelection( + baseOffset: baseOffset ?? this.baseOffset, + extentOffset: extentOffset ?? this.extentOffset, + affinity: affinity ?? this.affinity, + isDirectional: isDirectional ?? this.isDirectional, + first: first ?? this.first, + ); + } +} diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/selection/text_selection.dart similarity index 97% rename from lib/src/editor/widgets/text/text_selection.dart rename to lib/src/editor/widgets/text/selection/text_selection.dart index 65d3eb743..51b8dd2ba 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/selection/text_selection.dart @@ -4,9 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; - -import '../../../document/nodes/node.dart'; -import '../../editor.dart'; +import '../../../../document/nodes/node.dart'; +import '../../../editor.dart'; +import 'drag_text_selection.dart'; TextSelection localSelection(Node node, TextSelection selection, fromParent) { final base = fromParent ? node.offset : node.documentOffset; @@ -22,36 +22,6 @@ TextSelection localSelection(Node node, TextSelection selection, fromParent) { /// [start] handle always moves the [start]/[baseOffset] of the selection. enum _TextSelectionHandlePosition { start, end } -/// internal use, used to get drag direction information -class DragTextSelection extends TextSelection { - const DragTextSelection({ - super.affinity, - super.baseOffset = 0, - super.extentOffset = 0, - super.isDirectional, - this.first = true, - }); - - final bool first; - - @override - DragTextSelection copyWith({ - int? baseOffset, - int? extentOffset, - TextAffinity? affinity, - bool? isDirectional, - bool? first, - }) { - return DragTextSelection( - baseOffset: baseOffset ?? this.baseOffset, - extentOffset: extentOffset ?? this.extentOffset, - affinity: affinity ?? this.affinity, - isDirectional: isDirectional ?? this.isDirectional, - first: first ?? this.first, - ); - } -} - /// An object that manages a pair of text selection handles. /// /// The selection handles are displayed in the [Overlay] that most closely diff --git a/lib/src/editor/widgets/text/text_line.dart b/lib/src/editor/widgets/text/text_line.dart deleted file mode 100644 index a47d6f0e4..000000000 --- a/lib/src/editor/widgets/text/text_line.dart +++ /dev/null @@ -1,1584 +0,0 @@ -import 'dart:collection'; -import 'dart:math' as math; - -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/gestures.dart' - show GestureRecognizer, LongPressGestureRecognizer, TapGestureRecognizer; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' - show BoxParentData, PipelineOwner, BoxHitTestResult, RenderObjectVisitor; -import 'package:flutter/services.dart' show ClipboardData, Clipboard; -import 'package:url_launcher/url_launcher_string.dart' show launchUrlString; - -import '../../../../flutter_quill.dart'; -import '../../../common/utils/color.dart'; -import '../../../common/utils/font.dart'; -import '../../../common/utils/platform.dart'; -import '../../../document/nodes/container.dart' as container_node; -import '../../../document/nodes/leaf.dart' as leaf; -import '../box.dart'; -import '../delegate.dart'; -import '../keyboard_listener.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, - required this.composingRange, - this.textDirection, - this.customStyleBuilder, - this.customRecognizerBuilder, - this.customLinkPrefixes = const [], - super.key, - }); - - final Line line; - final TextDirection? textDirection; - final EmbedsBuilder embedBuilder; - final DefaultStyles styles; - final bool readOnly; - final QuillController controller; - final CustomStyleBuilder? customStyleBuilder; - final CustomRecognizerBuilder? customRecognizerBuilder; - final ValueChanged? onLaunchUrl; - final LinkActionPicker linkActionPicker; - final List customLinkPrefixes; - final TextRange composingRange; - - @override - State createState() => _TextLineState(); -} - -class _TextLineState extends State { - bool _metaOrControlPressed = false; - - UniqueKey _richTextKey = UniqueKey(); - - final _linkRecognizers = {}; - - QuillPressedKeys? _pressedKeys; - - void _pressedKeysChanged() { - final newValue = _pressedKeys!.metaPressed || _pressedKeys!.controlPressed; - if (_metaOrControlPressed != newValue) { - setState(() { - _metaOrControlPressed = newValue; - _linkRecognizers - ..forEach((key, value) { - value.dispose(); - }) - ..clear(); - }); - } - } - - 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 (isDesktopApp) { - 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(); - } - - /// Check if this line contains the placeholder attribute - bool get isPlaceholderLine => - widget.line.toDelta().first.attributes?.containsKey('placeholder') ?? - false; - - @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 - final lineStyle = _getLineStyle(widget.styles); - return EmbedProxy( - embedBuilder.build( - context, - widget.controller, - embed, - widget.readOnly, - false, - lineStyle, - ), - ); - } - } - final textSpan = _getTextSpanForWholeLine(); - final strutStyle = - StrutStyle.fromTextStyle(textSpan.style ?? const TextStyle()); - final textAlign = _getTextAlign(); - final child = RichText( - key: _richTextKey, - text: textSpan, - textAlign: textAlign, - textDirection: widget.textDirection, - strutStyle: strutStyle, - textScaler: MediaQuery.textScalerOf(context), - ); - return RichTextProxy( - textStyle: textSpan.style ?? const TextStyle(), - textAlign: textAlign, - textDirection: widget.textDirection!, - strutStyle: strutStyle, - locale: Localizations.localeOf(context), - textScaler: MediaQuery.textScalerOf(context), - child: child, - ); - } - - InlineSpan _getTextSpanForWholeLine() { - var 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 = []; - var textNodes = LinkedList(); - for (var child in widget.line.children) { - if (child is Embed) { - if (textNodes.isNotEmpty) { - textSpanChildren - .add(_buildTextSpan(widget.styles, textNodes, lineStyle)); - textNodes = LinkedList(); - } - // Creates correct node for custom embed - if (child.value.type == BlockEmbed.customType) { - child = Embed(CustomBlockEmbed.fromJsonString(child.value.data)) - ..applyStyle(child.style); - } - - if (child.value.type == BlockEmbed.formulaType) { - lineStyle = lineStyle.merge(_getInlineTextStyle( - child.style, - widget.styles, - widget.line.style, - false, - )); - } - - final embedBuilder = widget.embedBuilder(child); - final embedWidget = EmbedProxy( - embedBuilder.build( - context, - widget.controller, - child, - widget.readOnly, - true, - lineStyle, - ), - ); - 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 nodes, - TextStyle lineStyle, - ) { - if (nodes.isEmpty && kIsWeb) { - nodes = LinkedList()..add(leaf.QuillText('\u{200B}')); - } - - final isComposingRangeOutOfLine = !widget.composingRange.isValid || - widget.composingRange.isCollapsed || - (widget.composingRange.start < widget.line.documentOffset || - widget.composingRange.end > - widget.line.documentOffset + widget.line.length); - - if (isComposingRangeOutOfLine) { - final children = nodes - .map((node) => - _getTextSpanFromNode(defaultStyles, node, widget.line.style)) - .toList(growable: false); - return TextSpan(children: children, style: lineStyle); - } - - final children = nodes.expand((node) { - final child = - _getTextSpanFromNode(defaultStyles, node, widget.line.style); - final isNodeInComposingRange = - node.documentOffset <= widget.composingRange.start && - widget.composingRange.end <= node.documentOffset + node.length; - if (isNodeInComposingRange) { - return _splitAndApplyComposingStyle(node, child); - } else { - return [child]; - } - }).toList(growable: false); - - return TextSpan(children: children, style: lineStyle); - } - - // split the text nodes into composing and non-composing nodes - // and apply the composing style to the composing nodes - List _splitAndApplyComposingStyle(Node node, InlineSpan child) { - assert(widget.composingRange.isValid && !widget.composingRange.isCollapsed); - - final composingStart = widget.composingRange.start - node.documentOffset; - final composingEnd = widget.composingRange.end - node.documentOffset; - final text = child.toPlainText(); - - final textBefore = text.substring(0, composingStart); - final textComposing = text.substring(composingStart, composingEnd); - final textAfter = text.substring(composingEnd); - - final composingStyle = child.style - ?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? - const TextStyle(decoration: TextDecoration.underline); - - return [ - TextSpan( - text: textBefore, - style: child.style, - ), - TextSpan( - text: textComposing, - style: composingStyle, - ), - TextSpan( - text: textAfter, - style: child.style, - ), - ]; - } - - 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.h1: defaultStyles.h1!.style, - Attribute.h2: defaultStyles.h2!.style, - Attribute.h3: defaultStyles.h3!.style, - Attribute.h4: defaultStyles.h4!.style, - Attribute.h5: defaultStyles.h5!.style, - Attribute.h6: defaultStyles.h6!.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?.key == Attribute.list.key) { - toMerge = defaultStyles.lists!.style; - } - - textStyle = textStyle.merge(toMerge); - - final lineHeight = widget.line.style.attributes[Attribute.lineHeight.key]; - final x = { - LineHeightAttribute.lineHeightNormal: - defaultStyles.lineHeightNormal!.style, - LineHeightAttribute.lineHeightTight: defaultStyles.lineHeightTight!.style, - LineHeightAttribute.lineHeightOneAndHalf: - defaultStyles.lineHeightOneAndHalf!.style, - LineHeightAttribute.lineHeightDouble: - defaultStyles.lineHeightDouble!.style, - }; - - // If the lineHeight attribute isn't null, then get just the height param instead whole TextStyle - // to avoid modify the current style of the text line - textStyle = - textStyle.merge(textStyle.copyWith(height: x[lineHeight]?.height)); - - textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes); - - if (isPlaceholderLine) { - final oldStyle = textStyle; - textStyle = defaultStyles.placeHolder!.style; - textStyle = textStyle.merge(oldStyle.copyWith( - color: textStyle.color, - backgroundColor: textStyle.backgroundColor, - background: textStyle.background, - )); - } - - return textStyle; - } - - TextStyle _applyCustomAttributes( - TextStyle textStyle, Map attributes) { - if (widget.customStyleBuilder == null) { - return textStyle; - } - for (final key in attributes.keys) { - final attr = attributes[key]; - if (attr != null) { - /// Custom Attribute - final customAttr = widget.customStyleBuilder!.call(attr); - textStyle = textStyle.merge(customAttr); - } - } - return textStyle; - } - - /// Processes subscript and superscript attributed text. - /// - /// Reduces text fontSize and shifts down or up. Increases fontWeight to maintain balance with normal text. - /// Outputs characters individually to allow correct caret positioning and text selection. - InlineSpan _scriptSpan(String text, bool superScript, TextStyle style, - DefaultStyles defaultStyles) { - assert(text.isNotEmpty); - // - final lineStyle = style.fontSize == null || style.fontWeight == null - ? _getLineStyle(defaultStyles) - : null; - final fontWeight = FontWeight.lerp( - style.fontWeight ?? lineStyle?.fontWeight ?? FontWeight.normal, - FontWeight.w900, - 0.25); - final fontSize = style.fontSize ?? lineStyle?.fontSize ?? 16; - final y = (superScript ? -0.4 : 0.14) * fontSize; - final charStyle = style.copyWith( - fontFeatures: [], - fontWeight: fontWeight, - fontSize: fontSize * 0.7); - // - final offset = Offset(0, y); - final children = []; - for (final c in text.characters) { - children.add(WidgetSpan( - child: Transform.translate( - offset: offset, - child: Text( - c, - style: charStyle, - )))); - } - // - if (children.length > 1) { - return TextSpan(children: children); - } - return children[0]; - } - - InlineSpan _getTextSpanFromNode( - DefaultStyles defaultStyles, Node node, Style lineStyle) { - final textNode = node as leaf.QuillText; - final nodeStyle = textNode.style; - final isLink = nodeStyle.containsKey(Attribute.link.key) && - nodeStyle.attributes[Attribute.link.key]!.value != null; - final style = - _getInlineTextStyle(nodeStyle, defaultStyles, lineStyle, isLink); - if (widget.controller.configurations.requireScriptFontFeatures == false && - textNode.value.isNotEmpty) { - if (nodeStyle.containsKey(Attribute.script.key)) { - final attr = nodeStyle.attributes[Attribute.script.key]; - if (attr == Attribute.superscript || attr == Attribute.subscript) { - return _scriptSpan(textNode.value, attr == Attribute.superscript, - style, defaultStyles); - } - } - } - - if (!isLink && - !widget.readOnly && - !widget.line.style.attributes.containsKey('code-block') && - !widget.line.style.attributes.containsKey('placeholder') && - !isPlaceholderLine) { - // ignore: deprecated_member_use_from_same_package - final service = SpellCheckerServiceProvider.instance; - final spellcheckedSpans = service.checkSpelling(textNode.value); - if (spellcheckedSpans != null && spellcheckedSpans.isNotEmpty) { - return TextSpan( - children: spellcheckedSpans, - style: style, - mouseCursor: null, - ); - } - } - - final recognizer = _getRecognizer(node, isLink); - return TextSpan( - text: textNode.value, - style: style, - recognizer: recognizer, - mouseCursor: (recognizer != null) ? SystemMouseCursors.click : null, - ); - } - - TextStyle _getInlineTextStyle(Style nodeStyle, DefaultStyles defaultStyles, - Style lineStyle, bool isLink) { - var res = const TextStyle(); // This is inline text style - final color = nodeStyle.attributes[Attribute.color.key]; - - { - 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, defaultStyles); - } - 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.script.key)) { - if (nodeStyle.attributes.values.contains(Attribute.subscript)) { - res = _merge(res, defaultStyles.subscript!); - } else if (nodeStyle.attributes.values.contains(Attribute.superscript)) { - res = _merge(res, defaultStyles.superscript!); - } - } - - if (nodeStyle.containsKey(Attribute.inlineCode.key)) { - res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle)); - } - - final font = nodeStyle.attributes[Attribute.font.key]; - if (font != null && font.value != null) { - res = res.merge(TextStyle(fontFamily: font.value)); - } - - final size = nodeStyle.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, null, defaultStyles); - } - if (textColor != null) { - res = res.merge(TextStyle(color: textColor)); - } - } - - final background = nodeStyle.attributes[Attribute.background.key]; - if (background != null && background.value != null) { - final backgroundColor = - stringToColor(background.value, null, defaultStyles); - res = res.merge(TextStyle(backgroundColor: backgroundColor)); - } - - res = _applyCustomAttributes(res, nodeStyle.attributes); - return res; - } - - GestureRecognizer? _getRecognizer(Node segment, bool isLink) { - if (_linkRecognizers.containsKey(segment)) { - return _linkRecognizers[segment]!; - } - - if (widget.customRecognizerBuilder != null) { - final textNode = segment as leaf.QuillText; - final nodeStyle = textNode.style; - - nodeStyle.attributes.forEach((key, value) { - final recognizer = widget.customRecognizerBuilder!.call(value, segment); - if (recognizer != null) { - _linkRecognizers[segment] = recognizer; - return; - } - }); - } - - if (_linkRecognizers.containsKey(segment)) { - return _linkRecognizers[segment]!; - } - - if (isLink && canLaunchLinks) { - if (isDesktop || widget.readOnly) { - _linkRecognizers[segment] = TapGestureRecognizer() - ..onTap = () => _tapNodeLink(segment); - } else { - _linkRecognizers[segment] = LongPressGestureRecognizer() - ..onLongPress = () => _longPressLink(segment); - } - } - return _linkRecognizers[segment]; - } - - Future _launchUrl(String url) async { - await launchUrlString(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 (!(widget.customLinkPrefixes + linkPrefixes) - .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { - link = 'https://$link'; - } - launchUrl(link); - } - - Future _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: - 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 = []; - 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(decorations))); - } -} - -class EditableTextLine extends RenderObjectWidget { - const EditableTextLine( - this.line, - this.leading, - this.body, - this.horizontalSpacing, - this.verticalSpacing, - this.textDirection, - this.textSelection, - this.color, - this.enableInteractiveSelection, - this.hasFocus, - this.devicePixelRatio, - this.cursorCont, - this.inlineCodeStyle, - {super.key}); - - final Line line; - final Widget? leading; - final Widget body; - final HorizontalSpacing horizontalSpacing; - final VerticalSpacing verticalSpacing; - final TextDirection textDirection; - final TextSelection textSelection; - final Color color; - final bool enableInteractiveSelection; - final bool hasFocus; - final double devicePixelRatio; - final CursorCont cursorCont; - final InlineCodeStyle inlineCodeStyle; - - @override - RenderObjectElement createElement() { - return _TextLineElement(this); - } - - @override - RenderObject createRenderObject(BuildContext context) { - return RenderEditableTextLine( - line, - textDirection, - textSelection, - enableInteractiveSelection, - hasFocus, - devicePixelRatio, - _getPadding(), - color, - cursorCont, - inlineCodeStyle); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderEditableTextLine renderObject) { - renderObject - ..setLine(line) - ..setPadding(_getPadding()) - ..setTextDirection(textDirection) - ..setTextSelection(textSelection) - ..setColor(color) - ..setEnableInteractiveSelection(enableInteractiveSelection) - ..hasFocus = hasFocus - ..setDevicePixelRatio(devicePixelRatio) - ..setCursorCont(cursorCont) - ..setInlineCodeStyle(inlineCodeStyle); - } - - EdgeInsetsGeometry _getPadding() { - return EdgeInsetsDirectional.only( - start: horizontalSpacing.left, - end: horizontalSpacing.right, - 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? _selectedRects; - late Rect _caretPrototype; - InlineCodeStyle inlineCodeStyle; - final Map children = {}; - - Iterable 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 _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); - if (lineBoxes.isEmpty) { - // Empty line, line box is empty - return TextRange.collapsed(position.offset); - } - 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) { - double? maxOffset; - double limit() => maxOffset ??= - _body!.semanticBounds.height / preferredLineHeight(position) + 1; - bool checkLimit(double offset) => offset < 4.0 ? false : offset > limit(); - - /// Move up by fraction of the default font height, larger font sizes need larger offset, embed images need larger offset - for (var offset = 0.5;; offset += offset < 4 ? 0.25 : 1.0) { - final pos = _getPosition(position, -offset); - if (pos?.offset != position.offset || checkLimit(offset)) { - return pos; - } - } - } - - @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.QuillContainer 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 (isIos) { - _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 debugDescribeChildren() { - final value = []; - 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) { - if (textDirection == TextDirection.ltr) { - final parentData = _leading!.parentData as BoxParentData; - final effectiveOffset = offset + parentData.offset; - context.paintChild(_leading!, effectiveOffset); - } else { - final parentData = _leading!.parentData as BoxParentData; - final effectiveOffset = offset + parentData.offset; - context.paintChild( - _leading!, - Offset( - size.width - _leading!.size.width, - effectiveOffset.dy, - ), - ); - } - } - - 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.QuillText || - !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 super.line); - - final Map _slotToChildren = {}; - - @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; - } - } -} From 1a5bc138d8061e4241a5e639322009465fc017b5 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Mon, 23 Sep 2024 15:12:17 -0400 Subject: [PATCH 2/2] Fix: leaf.CustomBlockEmbed is missing --- lib/src/editor/widgets/text/line/text_line.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/src/editor/widgets/text/line/text_line.dart b/lib/src/editor/widgets/text/line/text_line.dart index c325e30bf..81646f670 100644 --- a/lib/src/editor/widgets/text/line/text_line.dart +++ b/lib/src/editor/widgets/text/line/text_line.dart @@ -11,6 +11,7 @@ import '../../../../common/utils/font.dart'; import '../../../../common/utils/platform.dart'; import '../../../../controller/quill_controller.dart'; import '../../../../document/attribute.dart'; +import '../../../../document/nodes/embeddable.dart'; import '../../../../document/nodes/leaf.dart' as leaf; import '../../../../document/nodes/line.dart'; import '../../../../document/nodes/node.dart'; @@ -149,9 +150,9 @@ class _TextLineState extends State { // Single child embeds can be expanded var embed = widget.line.children.single as leaf.Embed; // Creates correct node for custom embed - if (embed.value.type == leaf.BlockEmbed.customType) { + if (embed.value.type == BlockEmbed.customType) { embed = leaf.Embed( - leaf.CustomBlockEmbed.fromJsonString(embed.value.data), + CustomBlockEmbed.fromJsonString(embed.value.data), ); } final embedBuilder = widget.embedBuilder(embed); @@ -210,13 +211,12 @@ class _TextLineState extends State { textNodes = LinkedList(); } // Creates correct node for custom embed - if (child.value.type == leaf.BlockEmbed.customType) { - child = - leaf.Embed(leaf.CustomBlockEmbed.fromJsonString(child.value.data)) - ..applyStyle(child.style); + if (child.value.type == BlockEmbed.customType) { + child = leaf.Embed(CustomBlockEmbed.fromJsonString(child.value.data)) + ..applyStyle(child.style); } - if (child.value.type == leaf.BlockEmbed.formulaType) { + if (child.value.type == BlockEmbed.formulaType) { lineStyle = lineStyle.merge(_getInlineTextStyle( child.style, widget.styles,