From dd43ecab6d80e77f6677baab259b047ae71c2520 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Thu, 20 Feb 2025 16:57:37 -0400 Subject: [PATCH 1/4] Chore: reorganized members of the editor --- lib/flutter_quill.dart | 3 +- lib/src/common/utils/color.dart | 2 +- lib/src/editor/config/editor_config.dart | 3 +- lib/src/editor/editor.dart | 1513 +-------------- ...l_editor_collapsed_selection_boundary.dart | 37 + .../quill_editor_mixed_boundary.dart | 27 + .../quill_editor_text_expanded_boundary.dart | 33 + .../boundaries/editor_text_boundaries.dart | 12 + .../quill_editor_text_boundary.dart | 39 + .../text/quill_editor_character_boundary.dart | 48 + .../text/quill_editor_document_boundary.dart | 23 + .../quill_editor_line_break_boundary.dart | 29 + .../quill_editor_whitespace_boundary.dart | 36 + .../text/quill_editor_word_boundary.dart | 30 + .../raw_editor/config/raw_editor_config.dart | 4 +- .../raw_editor/editor_glyph_heights.dart | 12 + lib/src/editor/raw_editor/editor_state.dart | 31 + ..._editor_state_text_input_client_mixin.dart | 7 +- .../editor_keyboard_shortcut_actions.dart | 4 +- ...tor_keyboard_shortcut_actions_manager.dart | 12 +- .../quill_single_child_scroll_view.dart | 362 ---- lib/src/editor/raw_editor/raw_editor.dart | 40 - .../editor/raw_editor/raw_editor_state.dart | 22 +- .../raw_editor_text_boundaries.dart | 290 --- .../raw_editor_render_object.dart | 7 +- .../{ => render}/scribble_focusable.dart | 0 ...editor_state_selection_delegate_mixin.dart | 6 +- .../editor/render/abstract_render_editor.dart | 94 + .../quill_editor_text_selection_gestures.dart | 230 +++ .../render/render_editable_container_box.dart | 239 +++ lib/src/editor/render/render_editor.dart | 906 +++++++++ .../utils/quill_vertical_caret_movement.dart | 36 + .../render/utils/render_editor_utils.dart | 28 + lib/src/editor/widgets/cursor.dart | 201 +- lib/src/editor/widgets/delegate.dart | 6 +- .../widgets/painters/cursor_painter.dart | 113 ++ .../floating_cursor_painter.dart} | 5 +- .../widgets/proxies/baseline_proxy.dart | 80 + .../editor/widgets/proxies/embed_proxy.dart | 53 + lib/src/editor/widgets/proxies/proxy.dart | 4 + .../proxies/render_paragraph_proxy.dart | 134 ++ .../widgets/proxies/rich_text_proxy.dart | 57 + lib/src/editor/widgets/proxy.dart | 308 ---- .../editor/widgets/styles/block_styles.dart | 56 + .../editor/widgets/styles/cursor_style.dart | 88 + .../widgets/{ => styles}/default_styles.dart | 180 +- .../widgets/styles/inline_code_style.dart | 92 + .../editor/widgets/styles/quill_styles.dart | 26 + .../widgets/text/block/editable_block.dart | 62 + .../text/block/render_editable_block.dart | 315 ++++ .../widgets/text/{ => block}/text_block.dart | 403 +--- .../widgets/text/line/editable_text_line.dart | 86 + .../text/line/render_editable_line.dart | 835 +++++++++ .../editor/widgets/text/line/text_line.dart | 718 ++++++++ .../text/{ => selection}/text_selection.dart | 4 +- lib/src/editor/widgets/text/text_line.dart | 1615 ----------------- .../widgets/text/utils/text_block_utils.dart | 2 +- 57 files changed, 4689 insertions(+), 4919 deletions(-) create mode 100644 lib/src/editor/raw_editor/boundaries/combinators/quill_editor_collapsed_selection_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/combinators/quill_editor_mixed_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/combinators/quill_editor_text_expanded_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/editor_text_boundaries.dart create mode 100644 lib/src/editor/raw_editor/boundaries/quill_editor_text_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/text/quill_editor_character_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/text/quill_editor_document_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/text/quill_editor_line_break_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/text/quill_editor_whitespace_boundary.dart create mode 100644 lib/src/editor/raw_editor/boundaries/text/quill_editor_word_boundary.dart create mode 100644 lib/src/editor/raw_editor/editor_glyph_heights.dart create mode 100644 lib/src/editor/raw_editor/editor_state.dart rename lib/src/editor/raw_editor/{ => input}/raw_editor_state_text_input_client_mixin.dart (99%) delete mode 100644 lib/src/editor/raw_editor/quill_single_child_scroll_view.dart delete mode 100644 lib/src/editor/raw_editor/raw_editor_text_boundaries.dart rename lib/src/editor/raw_editor/{ => render}/raw_editor_render_object.dart (93%) rename lib/src/editor/raw_editor/{ => render}/scribble_focusable.dart (100%) rename lib/src/editor/raw_editor/{ => selection}/raw_editor_state_selection_delegate_mixin.dart (97%) create mode 100644 lib/src/editor/render/abstract_render_editor.dart create mode 100644 lib/src/editor/render/quill_editor_text_selection_gestures.dart create mode 100644 lib/src/editor/render/render_editable_container_box.dart create mode 100644 lib/src/editor/render/render_editor.dart create mode 100644 lib/src/editor/render/utils/quill_vertical_caret_movement.dart create mode 100644 lib/src/editor/render/utils/render_editor_utils.dart create mode 100644 lib/src/editor/widgets/painters/cursor_painter.dart rename lib/src/editor/widgets/{float_cursor.dart => painters/floating_cursor_painter.dart} (95%) create mode 100644 lib/src/editor/widgets/proxies/baseline_proxy.dart create mode 100644 lib/src/editor/widgets/proxies/embed_proxy.dart create mode 100644 lib/src/editor/widgets/proxies/proxy.dart create mode 100644 lib/src/editor/widgets/proxies/render_paragraph_proxy.dart create mode 100644 lib/src/editor/widgets/proxies/rich_text_proxy.dart delete mode 100644 lib/src/editor/widgets/proxy.dart create mode 100644 lib/src/editor/widgets/styles/block_styles.dart create mode 100644 lib/src/editor/widgets/styles/cursor_style.dart rename lib/src/editor/widgets/{ => styles}/default_styles.dart (70%) create mode 100644 lib/src/editor/widgets/styles/inline_code_style.dart create mode 100644 lib/src/editor/widgets/styles/quill_styles.dart create mode 100644 lib/src/editor/widgets/text/block/editable_block.dart create mode 100644 lib/src/editor/widgets/text/block/render_editable_block.dart rename lib/src/editor/widgets/text/{ => block}/text_block.dart (50%) create mode 100644 lib/src/editor/widgets/text/line/editable_text_line.dart create mode 100644 lib/src/editor/widgets/text/line/render_editable_line.dart create mode 100644 lib/src/editor/widgets/text/line/text_line.dart rename lib/src/editor/widgets/text/{ => selection}/text_selection.dart (99%) delete mode 100644 lib/src/editor/widgets/text/text_line.dart diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 9f8dd2e5e..57a36d615 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -20,13 +20,12 @@ export 'src/editor/embed/embed_editor_builder.dart'; export 'src/editor/raw_editor/builders/leading_block_builder.dart'; export 'src/editor/raw_editor/config/events/events.dart'; export 'src/editor/raw_editor/config/raw_editor_config.dart'; -export 'src/editor/raw_editor/quill_single_child_scroll_view.dart'; export 'src/editor/raw_editor/raw_editor.dart'; export 'src/editor/raw_editor/raw_editor_state.dart'; 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/styles/default_styles.dart'; export 'src/editor/widgets/text/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'; diff --git a/lib/src/common/utils/color.dart b/lib/src/common/utils/color.dart index 06724af61..2fd41840d 100644 --- a/lib/src/common/utils/color.dart +++ b/lib/src/common/utils/color.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../editor/widgets/default_styles.dart'; +import '../../editor/widgets/styles/default_styles.dart'; Color stringToColor(String? s, [Color? originalColor, DefaultStyles? defaultStyles]) { diff --git a/lib/src/editor/config/editor_config.dart b/lib/src/editor/config/editor_config.dart index eadb4c7c6..8948b5a94 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -10,10 +10,11 @@ import '../embed/embed_editor_builder.dart'; import '../raw_editor/builders/leading_block_builder.dart'; import '../raw_editor/config/events/events.dart'; import '../raw_editor/config/raw_editor_config.dart'; +import '../raw_editor/editor_state.dart'; import '../raw_editor/raw_editor.dart'; -import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; import '../widgets/link.dart'; +import '../widgets/styles/default_styles.dart'; import '../widgets/text/utils/text_block_utils.dart'; import 'search_config.dart'; diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 1641f1d6c..6d48f95af 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -1,120 +1,19 @@ -import 'dart:math' as math; - import 'package:flutter/cupertino.dart' show CupertinoTheme, cupertinoTextSelectionControls; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import '../common/utils/platform.dart'; import '../controller/quill_controller.dart'; -import '../document/attribute.dart'; -import '../document/document.dart'; -import '../document/nodes/container.dart' as container_node; import '../document/nodes/leaf.dart'; import 'config/editor_config.dart'; import 'embed/embed_editor_builder.dart'; import 'raw_editor/config/raw_editor_config.dart'; +import 'raw_editor/editor_state.dart'; import 'raw_editor/raw_editor.dart'; -import 'widgets/box.dart'; -import 'widgets/cursor.dart'; +import 'render/quill_editor_text_selection_gestures.dart'; import 'widgets/delegate.dart'; -import 'widgets/float_cursor.dart'; -import 'widgets/text/text_selection.dart'; - -/// Base interface for editable render objects. -abstract class RenderAbstractEditor implements TextLayoutMetrics { - TextSelection selectWordAtPosition(TextPosition position); - - TextSelection selectLineAtPosition(TextPosition position); - - /// Returns preferred line height at specified `position` in text. - double preferredLineHeight(TextPosition position); - - /// Returns [Rect] for caret in local coordinates - /// - /// Useful to enforce visibility of full caret at given position - Rect getLocalRectForCaret(TextPosition position); - - /// Returns the local coordinates of the endpoints of the given selection. - /// - /// If the selection is collapsed (and therefore occupies a single point), the - /// returned list is of length one. Otherwise, the selection is not collapsed - /// and the returned list is of length two. In this case, however, the two - /// points might actually be co-located (e.g., because of a bidirectional - /// selection that contains some text but whose ends meet in the middle). - TextPosition getPositionForOffset(Offset offset); - - /// Returns the local coordinates of the endpoints of the given selection. - /// - /// If the selection is collapsed (and therefore occupies a single point), the - /// returned list is of length one. Otherwise, the selection is not collapsed - /// and the returned list is of length two. In this case, however, the two - /// points might actually be co-located (e.g., because of a bidirectional - /// selection that contains some text but whose ends meet in the middle). - List getEndpointsForSelection( - TextSelection textSelection); - - /// Sets the screen position of the floating cursor and the text position - /// closest to the cursor. - /// `resetLerpValue` drives the size of the floating cursor. - /// See [EditorState.floatingCursorResetController]. - void setFloatingCursor(FloatingCursorDragState dragState, - Offset lastBoundedOffset, TextPosition lastTextPosition, - {double? resetLerpValue}); - - /// If [ignorePointer] is false (the default) then this method is called by - /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] - /// callback. - /// - /// When [ignorePointer] is true, an ancestor widget must respond to tap - /// down events by calling this method. - void handleTapDown(TapDownDetails details); - - /// Selects the set words of a paragraph in a given range of global positions. - /// - /// The first and last endpoints of the selection will always be at the - /// beginning and end of a word respectively. - /// - /// {@macro flutter.rendering.editable.select} - void selectWordsInRange( - Offset from, - Offset to, - SelectionChangedCause cause, - ); - - /// Move the selection to the beginning or end of a word. - /// - /// {@macro flutter.rendering.editable.select} - void selectWordEdge(SelectionChangedCause cause); - - /// - /// Returns the new selection. Note that the returned value may not be - /// yet reflected in the latest widget state. - /// - /// Returns null if no change occurred. - TextSelection? selectPositionAt( - {required Offset from, required SelectionChangedCause cause, Offset? to}); - - /// Select a word around the location of the last tap down. - /// - /// {@macro flutter.rendering.editable.select} - void selectWord(SelectionChangedCause cause); - - /// Move selection to the location of the last tap down. - /// - /// {@template flutter.rendering.editable.select} - /// This method is mainly used to translate user inputs in global positions - /// into a [TextSelection]. When used in conjunction with a [EditableText], - /// the selection change is fed back into [TextEditingController.selection]. - /// - /// If you have a [TextEditingController], it's generally easier to - /// programmatically manipulate its `value` or `selection` directly. - /// {@endtemplate} - void selectPosition({required SelectionChangedCause cause}); -} +import 'widgets/styles/cursor_style.dart'; class QuillEditor extends StatefulWidget { /// Quick start guide: @@ -198,7 +97,7 @@ class QuillEditorState extends State super.initState(); _editorKey = configurations.editorKey ?? GlobalKey(); _selectionGestureDetectorBuilder = - _QuillEditorSelectionGestureDetectorBuilder( + QuillEditorSelectionGestureDetectorBuilder( this, configurations.detectWordBoundary, ); @@ -382,1408 +281,4 @@ class QuillEditorState extends State @override bool get selectionEnabled => configurations.enableInteractiveSelection; - - /// Throws [StateError] if [_editorKey] is not connected to [QuillRawEditor] correctly. - /// - /// See also: [Flutter currentState docs](https://github.com/flutter/flutter/blob/b8211b3d941f2dcaa2db22e4572b74ede620cced/packages/flutter/lib/src/widgets/framework.dart#L179-L181) - EditorState get _requireEditorCurrentState { - final currentState = _editorKey.currentState; - if (currentState == null) { - throw StateError( - 'The $EditorState is null, ensure the $_editorKey is associated correctly with $QuillRawEditor.'); - } - return currentState; - } - - void _requestKeyboard() { - _requireEditorCurrentState.requestKeyboard(); - } -} - -class _QuillEditorSelectionGestureDetectorBuilder - extends EditorTextSelectionGestureDetectorBuilder { - _QuillEditorSelectionGestureDetectorBuilder( - this._state, this._detectWordBoundary) - : super(delegate: _state, detectWordBoundary: _detectWordBoundary); - - final QuillEditorState _state; - final bool _detectWordBoundary; - - @override - void onForcePressStart(ForcePressDetails details) { - super.onForcePressStart(details); - if (delegate.selectionEnabled && shouldShowSelectionToolbar) { - editor!.showToolbar(); - } - } - - @override - void onForcePressEnd(ForcePressDetails details) {} - - @override - void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { - if (_state.configurations.onSingleLongTapMoveUpdate != null) { - if (renderEditor != null && - _state.configurations.onSingleLongTapMoveUpdate!( - details, - renderEditor!.getPositionForOffset, - )) { - return; - } - } - if (!delegate.selectionEnabled) { - return; - } - - if (Theme.of(_state.context).isCupertino) { - renderEditor!.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); - } else { - renderEditor!.selectWordsInRange( - details.globalPosition - details.offsetFromOrigin, - details.globalPosition, - SelectionChangedCause.longPress, - ); - } - } - - bool _isPositionSelected(TapUpDetails details) { - if (_state.controller.document.isEmpty()) { - return false; - } - final pos = renderEditor!.getPositionForOffset(details.globalPosition); - final result = - editor!.widget.controller.document.querySegmentLeafNode(pos.offset); - final line = result.line; - if (line == null) { - return false; - } - final segmentLeaf = result.leaf; - if (segmentLeaf == null && line.length == 1) { - editor!.widget.controller.updateSelection( - TextSelection.collapsed(offset: pos.offset), - ChangeSource.local, - ); - return true; - } - return false; - } - - @override - void onTapDown(TapDownDetails details) { - if (_state.configurations.onTapDown != null) { - if (renderEditor != null && - _state.configurations.onTapDown!( - details, - renderEditor!.getPositionForOffset, - )) { - return; - } - } - super.onTapDown(details); - } - - bool isShiftClick(PointerDeviceKind deviceKind) { - final pressed = HardwareKeyboard.instance.logicalKeysPressed; - return deviceKind == PointerDeviceKind.mouse && - (pressed.contains(LogicalKeyboardKey.shiftLeft) || - pressed.contains(LogicalKeyboardKey.shiftRight)); - } - - @override - void onSingleTapUp(TapUpDetails details) { - if (_state.configurations.onTapUp != null && - renderEditor != null && - _state.configurations.onTapUp!( - details, - renderEditor!.getPositionForOffset, - )) { - return; - } - - editor!.hideToolbar(); - - try { - if (delegate.selectionEnabled && !_isPositionSelected(details)) { - if (isAppleOS || isDesktop) { - // added isDesktop() to enable extend selection in Windows platform - switch (details.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - // Precise devices should place the cursor at a precise position. - // If `Shift` key is pressed then - // extend current selection instead. - if (isShiftClick(details.kind)) { - renderEditor! - ..extendSelection(details.globalPosition, - cause: SelectionChangedCause.tap) - ..onSelectionCompleted(); - } else { - renderEditor! - ..selectPosition(cause: SelectionChangedCause.tap) - ..onSelectionCompleted(); - } - - break; - case PointerDeviceKind.touch: - case PointerDeviceKind.unknown: - // On macOS/iOS/iPadOS a touch tap places the cursor at the edge - // of the word. - if (_detectWordBoundary) { - renderEditor! - ..selectWordEdge(SelectionChangedCause.tap) - ..onSelectionCompleted(); - } else { - renderEditor! - ..selectPosition(cause: SelectionChangedCause.tap) - ..onSelectionCompleted(); - } - break; - case PointerDeviceKind.trackpad: - // TODO: Handle this case. - break; - } - } else { - renderEditor! - ..selectPosition(cause: SelectionChangedCause.tap) - ..onSelectionCompleted(); - } - } - } finally { - _state._requestKeyboard(); - } - } - - @override - void onSingleLongTapStart(LongPressStartDetails details) { - if (_state.configurations.onSingleLongTapStart != null) { - if (renderEditor != null && - _state.configurations.onSingleLongTapStart!( - details, - renderEditor!.getPositionForOffset, - )) { - return; - } - } - - if (delegate.selectionEnabled) { - if (Theme.of(_state.context).isCupertino) { - renderEditor!.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); - } else { - renderEditor!.selectWord(SelectionChangedCause.longPress); - Feedback.forLongPress(_state.context); - } - } - } - - @override - void onSingleLongTapEnd(LongPressEndDetails details) { - if (_state.configurations.onSingleLongTapEnd != null) { - if (renderEditor != null) { - if (_state.configurations.onSingleLongTapEnd!( - details, - renderEditor!.getPositionForOffset, - )) { - return; - } - - if (delegate.selectionEnabled) { - renderEditor!.onSelectionCompleted(); - } - } - } - super.onSingleLongTapEnd(details); - } -} - -/// Signature for the callback that reports when the user changes the selection -/// (including the cursor location). -/// -/// Used by [RenderEditor.onSelectionChanged]. -typedef TextSelectionChangedHandler = void Function( - TextSelection selection, SelectionChangedCause cause); - -/// Signature for the callback that reports when a selection action is actually -/// completed and ratified. Completion is defined as when the user input has -/// concluded for an entire selection action. For simple taps and keyboard input -/// events that change the selection, this callback is invoked immediately -/// following the TextSelectionChangedHandler. For long taps, the selection is -/// considered complete at the up event of a long tap. For drag selections, the -/// selection completes once the drag/pan event ends or is interrupted. -/// -/// Used by [RenderEditor.onSelectionCompleted]. -typedef TextSelectionCompletedHandler = void Function(); - -// The padding applied to text field. Used to determine the bounds when -// moving the floating cursor. -const EdgeInsets _kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5); - -// The additional size on the x and y axis with which to expand the prototype -// cursor to render the floating cursor in pixels. -const EdgeInsets _kFloatingCaretSizeIncrease = - EdgeInsets.symmetric(horizontal: 0.5, vertical: 1); - -/// Displays a document as a vertical list of document segments (lines -/// and blocks). -/// -/// Children of [RenderEditor] must be instances of [RenderEditableBox]. -class RenderEditor extends RenderEditableContainerBox - with RelayoutWhenSystemFontsChangeMixin - implements RenderAbstractEditor { - RenderEditor({ - required this.document, - required super.textDirection, - required bool hasFocus, - required this.selection, - required this.scrollable, - required LayerLink startHandleLayerLink, - required LayerLink endHandleLayerLink, - required super.padding, - required CursorCont cursorController, - required this.onSelectionChanged, - required this.onSelectionCompleted, - required super.scrollBottomInset, - required this.floatingCursorDisabled, - ViewportOffset? offset, - super.children, - EdgeInsets floatingCursorAddedMargin = - const EdgeInsets.fromLTRB(4, 4, 4, 5), - double? maxContentWidth, - }) : _hasFocus = hasFocus, - _extendSelectionOrigin = selection, - _startHandleLayerLink = startHandleLayerLink, - _endHandleLayerLink = endHandleLayerLink, - _cursorController = cursorController, - _maxContentWidth = maxContentWidth, - super( - container: document.root, - ); - - final CursorCont _cursorController; - final bool floatingCursorDisabled; - final bool scrollable; - - Document document; - TextSelection selection; - bool _hasFocus = false; - LayerLink _startHandleLayerLink; - LayerLink _endHandleLayerLink; - - /// Called when the selection changes. - TextSelectionChangedHandler onSelectionChanged; - TextSelectionCompletedHandler onSelectionCompleted; - final ValueNotifier _selectionStartInViewport = - ValueNotifier(true); - - ValueListenable get selectionStartInViewport => - _selectionStartInViewport; - - ValueListenable get selectionEndInViewport => _selectionEndInViewport; - final ValueNotifier _selectionEndInViewport = ValueNotifier(true); - - void _updateSelectionExtentsVisibility(Offset effectiveOffset) { - final visibleRegion = Offset.zero & size; - final startPosition = - TextPosition(offset: selection.start, affinity: selection.affinity); - final startOffset = _getOffsetForCaret(startPosition); - // TODO(justinmc): https://github.com/flutter/flutter/issues/31495 - // Check if the selection is visible with an approximation because a - // difference between rounded and unrounded values causes the caret to be - // reported as having a slightly (< 0.5) negative y offset. This rounding - // happens in paragraph.cc's layout and TextPainer's - // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and - // this can be changed to be a strict check instead of an approximation. - const visibleRegionSlop = 0.5; - _selectionStartInViewport.value = visibleRegion - .inflate(visibleRegionSlop) - .contains(startOffset + effectiveOffset); - - final endPosition = - TextPosition(offset: selection.end, affinity: selection.affinity); - final endOffset = _getOffsetForCaret(endPosition); - _selectionEndInViewport.value = visibleRegion - .inflate(visibleRegionSlop) - .contains(endOffset + effectiveOffset); - } - - // returns offset relative to this at which the caret will be painted - // given a global TextPosition - Offset _getOffsetForCaret(TextPosition position) { - final child = childAtPosition(position); - final childPosition = child.globalToLocalPosition(position); - final boxParentData = child.parentData as BoxParentData; - final localOffsetForCaret = child.getOffsetForCaret(childPosition); - return boxParentData.offset + localOffsetForCaret; - } - - void setDocument(Document doc) { - if (document == doc) { - return; - } - document = doc; - markNeedsLayout(); - } - - void setHasFocus(bool h) { - if (_hasFocus == h) { - return; - } - _hasFocus = h; - markNeedsSemanticsUpdate(); - } - - Offset get _paintOffset => Offset(0, -(offset?.pixels ?? 0.0)); - - ViewportOffset? get offset => _offset; - ViewportOffset? _offset; - - set offset(ViewportOffset? value) { - if (_offset == value) return; - if (attached) _offset?.removeListener(markNeedsPaint); - _offset = value; - if (attached) _offset?.addListener(markNeedsPaint); - markNeedsLayout(); - } - - void setSelection(TextSelection t) { - if (selection == t) { - return; - } - selection = t; - markNeedsPaint(); - - if (!_shiftPressed && !_isDragging) { - // Only update extend selection origin if Shift key is not pressed and - // user is not dragging selection. - _extendSelectionOrigin = selection; - } - } - - bool get _shiftPressed => - HardwareKeyboard.instance.logicalKeysPressed - .contains(LogicalKeyboardKey.shiftLeft) || - HardwareKeyboard.instance.logicalKeysPressed - .contains(LogicalKeyboardKey.shiftRight); - - void setStartHandleLayerLink(LayerLink value) { - if (_startHandleLayerLink == value) { - return; - } - _startHandleLayerLink = value; - markNeedsPaint(); - } - - void setEndHandleLayerLink(LayerLink value) { - if (_endHandleLayerLink == value) { - return; - } - _endHandleLayerLink = value; - markNeedsPaint(); - } - - void setScrollBottomInset(double value) { - if (scrollBottomInset == value) { - return; - } - scrollBottomInset = value; - markNeedsPaint(); - } - - double? _maxContentWidth; - - set maxContentWidth(double? value) { - if (_maxContentWidth == value) return; - _maxContentWidth = value; - markNeedsLayout(); - } - - @override - List getEndpointsForSelection( - TextSelection textSelection) { - if (textSelection.isCollapsed) { - final child = childAtPosition(textSelection.extent); - final localPosition = TextPosition( - offset: textSelection.extentOffset - child.container.offset, - affinity: textSelection.affinity, - ); - final localOffset = child.getOffsetForCaret(localPosition); - final parentData = child.parentData as BoxParentData; - return [ - TextSelectionPoint( - Offset(0, child.preferredLineHeight(localPosition)) + - localOffset + - parentData.offset, - null) - ]; - } - - final baseNode = _container.queryChild(textSelection.start, false).node; - - var baseChild = firstChild; - while (baseChild != null) { - if (baseChild.container == baseNode) { - break; - } - baseChild = childAfter(baseChild); - } - assert(baseChild != null); - - final baseParentData = baseChild!.parentData as BoxParentData; - final baseSelection = - localSelection(baseChild.container, textSelection, true); - var basePoint = baseChild.getBaseEndpointForSelection(baseSelection); - basePoint = TextSelectionPoint( - basePoint.point + baseParentData.offset, - basePoint.direction, - ); - - final extentNode = _container.queryChild(textSelection.end, false).node; - RenderEditableBox? extentChild = baseChild; - - /// Trap shortening the text of a link which can cause selection to extend off end of line - if (extentNode == null) { - while (true) { - final next = childAfter(extentChild); - if (next == null) { - break; - } - } - } else { - while (extentChild != null) { - if (extentChild.container == extentNode) { - break; - } - extentChild = childAfter(extentChild); - } - } - assert(extentChild != null); - - final extentParentData = extentChild!.parentData as BoxParentData; - final extentSelection = - localSelection(extentChild.container, textSelection, true); - var extentPoint = - extentChild.getExtentEndpointForSelection(extentSelection); - extentPoint = TextSelectionPoint( - extentPoint.point + extentParentData.offset, - extentPoint.direction, - ); - - return [basePoint, extentPoint]; - } - - Offset? _lastTapDownPosition; - - // Used on Desktop (mouse and keyboard enabled platforms) as base offset - // for extending selection, either with combination of `Shift` + Click or - // by dragging - TextSelection? _extendSelectionOrigin; - - @override - void handleTapDown(TapDownDetails details) { - _lastTapDownPosition = details.globalPosition; - } - - bool _isDragging = false; - - void handleDragStart(DragStartDetails details) { - _isDragging = true; - - final newSelection = selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.drag, - ); - - if (newSelection == null) return; - // Make sure to remember the origin for extend selection. - _extendSelectionOrigin = newSelection; - } - - void handleDragEnd(DragEndDetails details) { - _isDragging = false; - onSelectionCompleted(); - } - - @override - void selectWordsInRange( - Offset from, - Offset? to, - SelectionChangedCause cause, - ) { - final firstPosition = getPositionForOffset(from); - final firstWord = selectWordAtPosition(firstPosition); - final lastWord = - to == null ? firstWord : selectWordAtPosition(getPositionForOffset(to)); - - _handleSelectionChange( - TextSelection( - baseOffset: firstWord.base.offset, - extentOffset: lastWord.extent.offset, - affinity: firstWord.affinity, - ), - cause, - ); - } - - void _handleSelectionChange( - TextSelection nextSelection, - SelectionChangedCause cause, - ) { - final focusingEmpty = nextSelection.baseOffset == 0 && - nextSelection.extentOffset == 0 && - !_hasFocus; - if (nextSelection == selection && - cause != SelectionChangedCause.keyboard && - !focusingEmpty) { - return; - } - onSelectionChanged(nextSelection, cause); - } - - /// Extends current selection to the position closest to specified offset. - void extendSelection(Offset to, {required SelectionChangedCause cause}) { - /// The below logic does not exactly match the native version because - /// we do not allow swapping of base and extent positions. - assert(_extendSelectionOrigin != null); - final position = getPositionForOffset(to); - - if (position.offset < _extendSelectionOrigin!.baseOffset) { - _handleSelectionChange( - TextSelection( - baseOffset: position.offset, - extentOffset: _extendSelectionOrigin!.extentOffset, - affinity: selection.affinity, - ), - cause, - ); - } else if (position.offset > _extendSelectionOrigin!.extentOffset) { - _handleSelectionChange( - TextSelection( - baseOffset: _extendSelectionOrigin!.baseOffset, - extentOffset: position.offset, - affinity: selection.affinity, - ), - cause, - ); - } - } - - @override - void selectWordEdge(SelectionChangedCause cause) { - assert(_lastTapDownPosition != null); - final position = getPositionForOffset(_lastTapDownPosition!); - final child = childAtPosition(position); - final nodeOffset = child.container.offset; - final localPosition = TextPosition( - offset: position.offset - nodeOffset, - affinity: position.affinity, - ); - final localWord = child.getWordBoundary(localPosition); - final word = TextRange( - start: localWord.start + nodeOffset, - end: localWord.end + nodeOffset, - ); - - // Don't change selection if the selected word is a placeholder. - if (child.container.style.attributes - .containsKey(Attribute.placeholder.key)) { - return; - } - - if (position.offset - word.start <= 1 && word.end != position.offset) { - _handleSelectionChange( - TextSelection.collapsed(offset: word.start), - cause, - ); - } else { - _handleSelectionChange( - TextSelection.collapsed( - offset: word.end, affinity: TextAffinity.upstream), - cause, - ); - } - } - - @override - TextSelection? selectPositionAt({ - required Offset from, - required SelectionChangedCause cause, - Offset? to, - }) { - final fromPosition = getPositionForOffset(from); - final toPosition = to == null ? null : getPositionForOffset(to); - - var baseOffset = fromPosition.offset; - var extentOffset = fromPosition.offset; - if (toPosition != null) { - baseOffset = math.min(fromPosition.offset, toPosition.offset); - extentOffset = math.max(fromPosition.offset, toPosition.offset); - } - - final newSelection = TextSelection( - baseOffset: baseOffset, - extentOffset: extentOffset, - affinity: fromPosition.affinity, - ); - - // Call [onSelectionChanged] only when the selection actually changed. - _handleSelectionChange(newSelection, cause); - return newSelection; - } - - @override - void selectWord(SelectionChangedCause cause) { - selectWordsInRange(_lastTapDownPosition!, null, cause); - } - - @override - void selectPosition({required SelectionChangedCause cause}) { - selectPositionAt(from: _lastTapDownPosition!, cause: cause); - } - - @override - TextSelection selectWordAtPosition(TextPosition position) { - final word = getWordBoundary(position); - // When long-pressing past the end of the text, we want a collapsed cursor. - if (position.offset >= word.end) { - return TextSelection.fromPosition(position); - } - return TextSelection(baseOffset: word.start, extentOffset: word.end); - } - - @override - TextSelection selectLineAtPosition(TextPosition position) { - final line = getLineAtOffset(position); - - // When long-pressing past the end of the text, we want a collapsed cursor. - if (position.offset >= line.end) { - return TextSelection.fromPosition(position); - } - return TextSelection(baseOffset: line.start, extentOffset: line.end); - } - - @override - void performLayout() { - assert(() { - if (!scrollable || !constraints.hasBoundedHeight) return true; - throw FlutterError.fromParts([ - ErrorSummary('RenderEditableContainerBox must have ' - 'unlimited space along its main axis when it is scrollable.'), - ErrorDescription('RenderEditableContainerBox does not clip or' - ' resize its children, so it must be ' - 'placed in a parent that does not constrain the main ' - 'axis.'), - ErrorHint( - 'You probably want to put the RenderEditableContainerBox inside a ' - 'RenderViewport with a matching main axis or disable the ' - 'scrollable property.') - ]); - }()); - assert(() { - if (constraints.hasBoundedWidth) return true; - throw FlutterError.fromParts([ - ErrorSummary('RenderEditableContainerBox must have a bounded' - ' constraint for its cross axis.'), - ErrorDescription('RenderEditableContainerBox forces its children to ' - "expand to fit the RenderEditableContainerBox's container, " - 'so it must be placed in a parent that constrains the cross ' - 'axis to a finite dimension.'), - ]); - }()); - - resolvePadding(); - assert(resolvedPadding != null); - - var mainAxisExtent = resolvedPadding!.top; - var child = firstChild; - final innerConstraints = BoxConstraints.tightFor( - width: math.min( - _maxContentWidth ?? double.infinity, constraints.maxWidth)) - .deflate(resolvedPadding!); - final leftOffset = _maxContentWidth == null - ? 0.0 - : math.max((constraints.maxWidth - _maxContentWidth!) / 2, 0); - while (child != null) { - child.layout(innerConstraints, parentUsesSize: true); - final childParentData = child.parentData as EditableContainerParentData - ..offset = Offset(resolvedPadding!.left + leftOffset, mainAxisExtent); - mainAxisExtent += child.size.height; - assert(child.parentData == childParentData); - child = childParentData.nextSibling; - } - mainAxisExtent += resolvedPadding!.bottom; - size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); - - assert(size.isFinite); - } - - @override - void paint(PaintingContext context, Offset offset) { - if (_hasFocus && - _cursorController.show.value && - !_cursorController.style.paintAboveText) { - _paintFloatingCursor(context, offset); - } - defaultPaint(context, offset); - _updateSelectionExtentsVisibility(offset + _paintOffset); - _paintHandleLayers(context, getEndpointsForSelection(selection)); - - if (_hasFocus && - _cursorController.show.value && - _cursorController.style.paintAboveText) { - _paintFloatingCursor(context, offset); - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } - - void _paintHandleLayers( - PaintingContext context, List endpoints) { - var startPoint = endpoints[0].point; - startPoint = Offset( - startPoint.dx.clamp(0.0, size.width), - startPoint.dy.clamp(0.0, size.height), - ); - context.pushLayer( - LeaderLayer(link: _startHandleLayerLink, offset: startPoint), - super.paint, - Offset.zero, - ); - if (endpoints.length == 2) { - var endPoint = endpoints[1].point; - endPoint = Offset( - endPoint.dx.clamp(0.0, size.width), - endPoint.dy.clamp(0.0, size.height), - ); - context.pushLayer( - LeaderLayer(link: _endHandleLayerLink, offset: endPoint), - super.paint, - Offset.zero, - ); - } - } - - @override - double preferredLineHeight(TextPosition position) { - final child = childAtPosition(position); - return child.preferredLineHeight( - TextPosition(offset: position.offset - child.container.offset)); - } - - @override - TextPosition getPositionForOffset(Offset offset) { - final local = globalToLocal(offset); - final child = childAtOffset(local); - - final parentData = child.parentData as BoxParentData; - final localOffset = local - parentData.offset; - final localPosition = child.getPositionForOffset(localOffset); - return TextPosition( - offset: localPosition.offset + child.container.offset, - affinity: localPosition.affinity, - ); - } - - /// Returns the y-offset of the editor at which [selection] is visible. - /// - /// The offset is the distance from the top of the editor and is the minimum - /// from the current scroll position until [selection] becomes visible. - /// Returns null if [selection] is already visible. - /// - /// Finds the closest scroll offset that fully reveals the editing cursor. - /// - /// The `scrollOffset` parameter represents current scroll offset in the - /// parent viewport. - /// - /// The `offsetInViewport` parameter represents the editor's vertical offset - /// in the parent viewport. This value should normally be 0.0 if this editor - /// is the only child of the viewport or if it's the topmost child. Otherwise - /// it should be a positive value equal to total height of all siblings of - /// this editor from above it. - /// - /// Returns `null` if the cursor is currently visible. - double? getOffsetToRevealCursor( - double viewportHeight, double scrollOffset, double offsetInViewport) { - // Endpoints coordinates represents lower left or lower right corner of - // the selection. If we want to scroll up to reveal the caret we need to - // adjust the dy value by the height of the line. We also add a small margin - // so that the caret is not too close to the edge of the viewport. - final endpoints = getEndpointsForSelection(selection); - - // when we drag the right handle, we should get the last point - TextSelectionPoint endpoint; - if (selection.isCollapsed) { - endpoint = endpoints.first; - } else { - if (selection is DragTextSelection) { - endpoint = (selection as DragTextSelection).first - ? endpoints.first - : endpoints.last; - } else { - endpoint = endpoints.first; - } - } - - // Collapsed selection => caret - final child = childAtPosition(selection.extent); - const kMargin = 8.0; - - final caretTop = endpoint.point.dy - - child.preferredLineHeight(TextPosition( - offset: selection.extentOffset - child.container.documentOffset)) - - kMargin + - offsetInViewport + - scrollBottomInset; - final caretBottom = - endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset; - double? dy; - if (caretTop < scrollOffset) { - dy = caretTop; - } else if (caretBottom > scrollOffset + viewportHeight) { - dy = caretBottom - viewportHeight; - } - if (dy == null) { - return null; - } - // Clamping to 0.0 so that the content does not jump unnecessarily. - return math.max(dy, 0); - } - - @override - Rect getLocalRectForCaret(TextPosition position) { - final targetChild = childAtPosition(position); - final localPosition = targetChild.globalToLocalPosition(position); - - final childLocalRect = targetChild.getLocalRectForCaret(localPosition); - - final boxParentData = targetChild.parentData as BoxParentData; - return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); - } - - // Start floating cursor - - FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter( - floatingCursorRect: _floatingCursorRect, - style: _cursorController.style, - ); - - bool _floatingCursorOn = false; - Rect? _floatingCursorRect; - - TextPosition get floatingCursorTextPosition => _floatingCursorTextPosition; - late TextPosition _floatingCursorTextPosition; - - // The relative origin in relation to the distance the user has theoretically - // dragged the floating cursor offscreen. - // This value is used to account for the difference - // in the rendering position and the raw offset value. - Offset _relativeOrigin = Offset.zero; - Offset? _previousOffset; - bool _resetOriginOnLeft = false; - bool _resetOriginOnRight = false; - bool _resetOriginOnTop = false; - bool _resetOriginOnBottom = false; - - /// Returns the position within the editor closest to the raw cursor offset. - Offset calculateBoundedFloatingCursorOffset( - Offset rawCursorOffset, double preferredLineHeight) { - var deltaPosition = Offset.zero; - final topBound = _kFloatingCursorAddedMargin.top; - final bottomBound = - size.height - preferredLineHeight + _kFloatingCursorAddedMargin.bottom; - final leftBound = _kFloatingCursorAddedMargin.left; - final rightBound = size.width - _kFloatingCursorAddedMargin.right; - - if (_previousOffset != null) { - deltaPosition = rawCursorOffset - _previousOffset!; - } - - // If the raw cursor offset has gone off an edge, - // we want to reset the relative origin of - // the dragging when the user drags back into the field. - if (_resetOriginOnLeft && deltaPosition.dx > 0) { - _relativeOrigin = - Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); - _resetOriginOnLeft = false; - } else if (_resetOriginOnRight && deltaPosition.dx < 0) { - _relativeOrigin = - Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); - _resetOriginOnRight = false; - } - if (_resetOriginOnTop && deltaPosition.dy > 0) { - _relativeOrigin = - Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); - _resetOriginOnTop = false; - } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { - _relativeOrigin = - Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); - _resetOriginOnBottom = false; - } - - final currentX = rawCursorOffset.dx - _relativeOrigin.dx; - final currentY = rawCursorOffset.dy - _relativeOrigin.dy; - final double adjustedX = - math.min(math.max(currentX, leftBound), rightBound); - final double adjustedY = - math.min(math.max(currentY, topBound), bottomBound); - final adjustedOffset = Offset(adjustedX, adjustedY); - - if (currentX < leftBound && deltaPosition.dx < 0) { - _resetOriginOnLeft = true; - } else if (currentX > rightBound && deltaPosition.dx > 0) { - _resetOriginOnRight = true; - } - if (currentY < topBound && deltaPosition.dy < 0) { - _resetOriginOnTop = true; - } else if (currentY > bottomBound && deltaPosition.dy > 0) { - _resetOriginOnBottom = true; - } - - _previousOffset = rawCursorOffset; - - return adjustedOffset; - } - - @override - void setFloatingCursor(FloatingCursorDragState dragState, - Offset boundedOffset, TextPosition textPosition, - {double? resetLerpValue}) { - if (floatingCursorDisabled) return; - - if (dragState == FloatingCursorDragState.Start) { - _relativeOrigin = Offset.zero; - _previousOffset = null; - _resetOriginOnBottom = false; - _resetOriginOnTop = false; - _resetOriginOnRight = false; - _resetOriginOnBottom = false; - } - _floatingCursorOn = dragState != FloatingCursorDragState.End; - if (_floatingCursorOn) { - _floatingCursorTextPosition = textPosition; - final sizeAdjustment = resetLerpValue != null - ? EdgeInsets.lerp( - _kFloatingCaretSizeIncrease, EdgeInsets.zero, resetLerpValue)! - : _kFloatingCaretSizeIncrease; - final child = childAtPosition(textPosition); - final caretPrototype = - child.getCaretPrototype(child.globalToLocalPosition(textPosition)); - _floatingCursorRect = - sizeAdjustment.inflateRect(caretPrototype).shift(boundedOffset); - _cursorController - .setFloatingCursorTextPosition(_floatingCursorTextPosition); - } else { - _floatingCursorRect = null; - _cursorController.setFloatingCursorTextPosition(null); - } - markNeedsPaint(); - } - - void _paintFloatingCursor(PaintingContext context, Offset offset) { - _floatingCursorPainter.paint(context.canvas); - } - - // End floating cursor - - // Start TextLayoutMetrics implementation - - /// Return a [TextSelection] containing the line of the given [TextPosition]. - @override - TextSelection getLineAtOffset(TextPosition position) { - final child = childAtPosition(position); - final nodeOffset = child.container.offset; - final localPosition = TextPosition( - offset: position.offset - nodeOffset, affinity: position.affinity); - final localLineRange = child.getLineBoundary(localPosition); - final line = TextRange( - start: localLineRange.start + nodeOffset, - end: localLineRange.end + nodeOffset, - ); - return TextSelection(baseOffset: line.start, extentOffset: line.end); - } - - @override - TextRange getWordBoundary(TextPosition position) { - final child = childAtPosition(position); - final nodeOffset = child.container.offset; - final localPosition = TextPosition( - offset: position.offset - nodeOffset, affinity: position.affinity); - final localWord = child.getWordBoundary(localPosition); - return TextRange( - start: localWord.start + nodeOffset, - end: localWord.end + nodeOffset, - ); - } - - /// Returns the TextPosition after moving by the vertical offset. - TextPosition getTextPositionMoveVertical( - TextPosition position, double verticalOffset) { - final caretOfs = localToGlobal(_getOffsetForCaret(position)); - return getPositionForOffset(caretOfs.translate(0, verticalOffset)); - } - - /// Returns the TextPosition above the given offset into the text. - /// - /// If the offset is already on the first line, the offset of the first - /// character will be returned. - @override - TextPosition getTextPositionAbove(TextPosition position) { - final child = childAtPosition(position); - final localPosition = - TextPosition(offset: position.offset - child.container.documentOffset); - - var newPosition = child.getPositionAbove(localPosition); - - if (newPosition == null) { - // There was no text above in the current child, check the direct - // sibling. - final sibling = childBefore(child); - if (sibling == null) { - // reached beginning of the document, move to the - // first character - newPosition = const TextPosition(offset: 0); - } else { - final caretOffset = child.getOffsetForCaret(localPosition); - final testPosition = TextPosition(offset: sibling.container.length - 1); - final testOffset = sibling.getOffsetForCaret(testPosition); - final finalOffset = Offset(caretOffset.dx, testOffset.dy); - final siblingPosition = sibling.getPositionForOffset(finalOffset); - newPosition = TextPosition( - offset: sibling.container.documentOffset + siblingPosition.offset); - } - } else { - newPosition = TextPosition( - offset: child.container.documentOffset + newPosition.offset); - } - return newPosition; - } - - /// Returns the TextPosition below the given offset into the text. - /// - /// If the offset is already on the last line, the offset of the last - /// character will be returned. - @override - TextPosition getTextPositionBelow(TextPosition position) { - final child = childAtPosition(position); - final localPosition = TextPosition( - offset: position.offset - child.container.documentOffset, - ); - - var newPosition = child.getPositionBelow(localPosition); - - if (newPosition == null) { - // There was no text below in the current child, check the direct sibling. - final sibling = childAfter(child); - if (sibling == null) { - // reached end of the document, move to the - // last character - newPosition = TextPosition(offset: document.length - 1); - } else { - final caretOffset = child.getOffsetForCaret(localPosition); - const testPosition = TextPosition(offset: 0); - final testOffset = sibling.getOffsetForCaret(testPosition); - final finalOffset = Offset(caretOffset.dx, testOffset.dy); - final siblingPosition = sibling.getPositionForOffset(finalOffset); - newPosition = TextPosition( - offset: sibling.container.documentOffset + siblingPosition.offset, - ); - } - } else { - newPosition = TextPosition( - offset: child.container.documentOffset + newPosition.offset, - ); - } - return newPosition; - } - - // End TextLayoutMetrics implementation - - QuillVerticalCaretMovementRun startVerticalCaretMovement( - TextPosition startPosition) { - return QuillVerticalCaretMovementRun._( - this, - startPosition, - ); - } - - @override - void systemFontsDidChange() { - super.systemFontsDidChange(); - markNeedsLayout(); - } -} - -class QuillVerticalCaretMovementRun implements Iterator { - QuillVerticalCaretMovementRun._( - this._editor, - this._currentTextPosition, - ); - - TextPosition _currentTextPosition; - - final RenderEditor _editor; - - @override - TextPosition get current { - return _currentTextPosition; - } - - @override - bool moveNext() { - _currentTextPosition = _editor.getTextPositionBelow(_currentTextPosition); - return true; - } - - bool movePrevious() { - _currentTextPosition = _editor.getTextPositionAbove(_currentTextPosition); - return true; - } - - void moveVertical(double verticalOffset) { - _currentTextPosition = _editor.getTextPositionMoveVertical( - _currentTextPosition, verticalOffset); - } -} - -class EditableContainerParentData - extends ContainerBoxParentData {} - -/// Multi-child render box of editable content. -/// -/// Common ancestor for [RenderEditor] and [RenderEditableTextBlock]. -class RenderEditableContainerBox extends RenderBox - with - ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin { - RenderEditableContainerBox({ - required container_node.QuillContainer container, - required this.textDirection, - required this.scrollBottomInset, - required EdgeInsetsGeometry padding, - List? children, - }) : assert(padding.isNonNegative), - _container = container, - _padding = padding { - addAll(children); - } - - container_node.QuillContainer _container; - TextDirection textDirection; - EdgeInsetsGeometry _padding; - double scrollBottomInset; - EdgeInsets? _resolvedPadding; - - container_node.QuillContainer get container => _container; - - void setContainer(container_node.QuillContainer c) { - if (_container == c) { - return; - } - _container = c; - markNeedsLayout(); - } - - EdgeInsetsGeometry getPadding() => _padding; - - void setPadding(EdgeInsetsGeometry value) { - assert(value.isNonNegative); - if (_padding == value) { - return; - } - _padding = value; - _markNeedsPaddingResolution(); - } - - EdgeInsets? get resolvedPadding => _resolvedPadding; - - void resolvePadding() { - if (_resolvedPadding != null) { - return; - } - _resolvedPadding = _padding.resolve(textDirection); - _resolvedPadding = _resolvedPadding!.copyWith(left: _resolvedPadding!.left); - - assert(_resolvedPadding!.isNonNegative); - } - - RenderEditableBox childAtPosition(TextPosition position) { - assert(firstChild != null); - final targetNode = container.queryChild(position.offset, false).node; - - var targetChild = firstChild; - while (targetChild != null) { - if (targetChild.container == targetNode) { - break; - } - final newChild = childAfter(targetChild); - if (newChild == null) { - // At start of document fails to find the position - targetChild = childAtOffset(const Offset(0, 0)); - break; - } - targetChild = newChild; - } - if (targetChild == null) { - throw 'targetChild should not be null'; - } - return targetChild; - } - - void _markNeedsPaddingResolution() { - _resolvedPadding = null; - markNeedsLayout(); - } - - /// Returns child of this container located at the specified local `offset`. - /// - /// If `offset` is above this container (offset.dy is negative) returns - /// the first child. Likewise, if `offset` is below this container then - /// returns the last child. - RenderEditableBox childAtOffset(Offset offset) { - assert(firstChild != null); - resolvePadding(); - - if (offset.dy <= _resolvedPadding!.top) { - return firstChild!; - } - if (offset.dy >= size.height - _resolvedPadding!.bottom) { - return lastChild!; - } - - var child = firstChild; - final dx = -offset.dx; - var dy = _resolvedPadding!.top; - while (child != null) { - if (child.size.contains(offset.translate(dx, -dy))) { - return child; - } - dy += child.size.height; - child = childAfter(child); - } - - // this case possible, when editor not scrollable, - // but minHeight > content height and tap was under content - return lastChild!; - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is EditableContainerParentData) { - return; - } - - child.parentData = EditableContainerParentData(); - } - - @override - void performLayout() { - assert(constraints.hasBoundedWidth); - resolvePadding(); - assert(_resolvedPadding != null); - - var mainAxisExtent = _resolvedPadding!.top; - var child = firstChild; - final innerConstraints = - BoxConstraints.tightFor(width: constraints.maxWidth) - .deflate(_resolvedPadding!); - while (child != null) { - child.layout(innerConstraints, parentUsesSize: true); - final childParentData = (child.parentData as EditableContainerParentData) - ..offset = Offset(_resolvedPadding!.left, mainAxisExtent); - mainAxisExtent += child.size.height; - assert(child.parentData == childParentData); - child = childParentData.nextSibling; - } - mainAxisExtent += _resolvedPadding!.bottom; - size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); - - assert(size.isFinite); - } - - double _getIntrinsicCrossAxis(double Function(RenderBox child) childSize) { - var extent = 0.0; - var child = firstChild; - while (child != null) { - extent = math.max(extent, childSize(child)); - final childParentData = child.parentData as EditableContainerParentData; - child = childParentData.nextSibling; - } - return extent; - } - - double _getIntrinsicMainAxis(double Function(RenderBox child) childSize) { - var extent = 0.0; - var child = firstChild; - while (child != null) { - extent += childSize(child); - final childParentData = child.parentData as EditableContainerParentData; - child = childParentData.nextSibling; - } - return extent; - } - - @override - double computeMinIntrinsicWidth(double height) { - resolvePadding(); - return _getIntrinsicCrossAxis((child) { - final childHeight = math.max( - 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); - return child.getMinIntrinsicWidth(childHeight) + - _resolvedPadding!.left + - _resolvedPadding!.right; - }); - } - - @override - double computeMaxIntrinsicWidth(double height) { - resolvePadding(); - return _getIntrinsicCrossAxis((child) { - final childHeight = math.max( - 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); - return child.getMaxIntrinsicWidth(childHeight) + - _resolvedPadding!.left + - _resolvedPadding!.right; - }); - } - - @override - double computeMinIntrinsicHeight(double width) { - resolvePadding(); - return _getIntrinsicMainAxis((child) { - final childWidth = math.max( - 0, width - _resolvedPadding!.left + _resolvedPadding!.right); - return child.getMinIntrinsicHeight(childWidth) + - _resolvedPadding!.top + - _resolvedPadding!.bottom; - }); - } - - @override - double computeMaxIntrinsicHeight(double width) { - resolvePadding(); - return _getIntrinsicMainAxis((child) { - final childWidth = math.max( - 0, width - _resolvedPadding!.left + _resolvedPadding!.right); - return child.getMaxIntrinsicHeight(childWidth) + - _resolvedPadding!.top + - _resolvedPadding!.bottom; - }); - } - - @override - double computeDistanceToActualBaseline(TextBaseline baseline) { - resolvePadding(); - return defaultComputeDistanceToFirstActualBaseline(baseline)! + - _resolvedPadding!.top; - } } diff --git a/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_collapsed_selection_boundary.dart b/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_collapsed_selection_boundary.dart new file mode 100644 index 000000000..cd42de62d --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_collapsed_selection_boundary.dart @@ -0,0 +1,37 @@ +import 'package:flutter/services.dart'; +import '../editor_text_boundaries.dart'; + +// Force the innerTextBoundary to interpret the input [TextPosition]s as caret +// locations instead of code unit positions. +// +// The innerTextBoundary must be a [_TextBoundary] that interprets the input +// [TextPosition]s as code unit positions. +class QuillEditorCollapsedSelectionBoundary extends QuillEditorTextBoundary { + QuillEditorCollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); + + final QuillEditorTextBoundary innerTextBoundary; + final bool isForward; + + @override + TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getLeadingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getLeadingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getTrailingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getTrailingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_mixed_boundary.dart b/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_mixed_boundary.dart new file mode 100644 index 000000000..9165f391d --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_mixed_boundary.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import '../quill_editor_text_boundary.dart'; + +// A _TextBoundary that creates a [TextRange] where its start is from the +// specified leading text boundary and its end is from the specified trailing +// text boundary. +class QuillEditorMixedBoundary extends QuillEditorTextBoundary { + QuillEditorMixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); + + final QuillEditorTextBoundary leadingTextBoundary; + final QuillEditorTextBoundary trailingTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(leadingTextBoundary.textEditingValue == + trailingTextBoundary.textEditingValue); + return leadingTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + leadingTextBoundary.getLeadingTextBoundaryAt(position); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => + trailingTextBoundary.getTrailingTextBoundaryAt(position); +} diff --git a/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_text_expanded_boundary.dart b/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_text_expanded_boundary.dart new file mode 100644 index 000000000..68df3c489 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/combinators/quill_editor_text_expanded_boundary.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; + +import '../quill_editor_text_boundary.dart'; + +// Expands the innerTextBoundary with outerTextBoundary. +class QuillEditorExpandedTextBoundary extends QuillEditorTextBoundary { + QuillEditorExpandedTextBoundary( + this.innerTextBoundary, this.outerTextBoundary); + + final QuillEditorTextBoundary innerTextBoundary; + final QuillEditorTextBoundary outerTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(innerTextBoundary.textEditingValue == + outerTextBoundary.textEditingValue); + return innerTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getLeadingTextBoundaryAt( + innerTextBoundary.getLeadingTextBoundaryAt(position), + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getTrailingTextBoundaryAt( + innerTextBoundary.getTrailingTextBoundaryAt(position), + ); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/editor_text_boundaries.dart b/lib/src/editor/raw_editor/boundaries/editor_text_boundaries.dart new file mode 100644 index 000000000..74e4534ce --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/editor_text_boundaries.dart @@ -0,0 +1,12 @@ +// combinators +export 'combinators/quill_editor_collapsed_selection_boundary.dart'; +export 'combinators/quill_editor_mixed_boundary.dart'; +export 'combinators/quill_editor_text_expanded_boundary.dart'; +// base +export 'quill_editor_text_boundary.dart'; +// text +export 'text/quill_editor_character_boundary.dart'; +export 'text/quill_editor_document_boundary.dart'; +export 'text/quill_editor_line_break_boundary.dart'; +export 'text/quill_editor_whitespace_boundary.dart'; +export 'text/quill_editor_word_boundary.dart'; diff --git a/lib/src/editor/raw_editor/boundaries/quill_editor_text_boundary.dart b/lib/src/editor/raw_editor/boundaries/quill_editor_text_boundary.dart new file mode 100644 index 000000000..a387ccd25 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/quill_editor_text_boundary.dart @@ -0,0 +1,39 @@ +import 'package:flutter/services.dart'; + +/// An interface for retrieving the logical text boundary +/// (left-closed-right-open) +/// at a given location in a document. +/// +/// Depending on the implementation of the [QuillEditorTextBoundary], the input +/// [TextPosition] can either point to a code unit, or a position between 2 code +/// units (which can be visually represented by the caret if the selection were +/// to collapse to that position). +/// +/// For example, [QuillEditorLineBreak] interprets the input [TextPosition] as a caret +/// location, since in Flutter the caret is generally painted between the +/// character the [TextPosition] points to and its previous character, and +/// [QuillEditorLineBreak] cares about the affinity of the input [TextPosition]. Most +/// other text boundaries however, interpret the input [TextPosition] as the +/// location of a code unit in the document, since it's easier to reason about +/// the text boundary given a code unit in the text. +/// +/// To convert a "code-unit-based" [QuillEditorTextBoundary] to "caret-location-based", +/// use the [QuillEditorCollapsedSelectionBoundary] combinator. +abstract class QuillEditorTextBoundary { + const QuillEditorTextBoundary(); + + TextEditingValue get textEditingValue; + + /// Returns the leading text boundary at the given location, inclusive. + TextPosition getLeadingTextBoundaryAt(TextPosition position); + + /// Returns the trailing text boundary at the given location, exclusive. + TextPosition getTrailingTextBoundaryAt(TextPosition position); + + TextRange getTextBoundaryAt(TextPosition position) { + return TextRange( + start: getLeadingTextBoundaryAt(position).offset, + end: getTrailingTextBoundaryAt(position).offset, + ); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/text/quill_editor_character_boundary.dart b/lib/src/editor/raw_editor/boundaries/text/quill_editor_character_boundary.dart new file mode 100644 index 000000000..d1b21a962 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/text/quill_editor_character_boundary.dart @@ -0,0 +1,48 @@ +import 'dart:math' as math; +import 'package:flutter/widgets.dart'; + +import '../quill_editor_text_boundary.dart'; + +// Most apps delete the entire grapheme when the backspace key is pressed. +// Also always put the new caret location to character boundaries to avoid +// sending malformed UTF-16 code units to the paragraph builder. +class QuillEditorCharacterBoundary extends QuillEditorTextBoundary { + const QuillEditorCharacterBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + return TextPosition( + offset: + CharacterRange.at(textEditingValue.text, position.offset, endOffset) + .stringBeforeLength, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextPosition( + offset: textEditingValue.text.length - range.stringAfterLength, + ); + } + + @override + TextRange getTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextRange( + start: range.stringBeforeLength, + end: textEditingValue.text.length - range.stringAfterLength, + ); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/text/quill_editor_document_boundary.dart b/lib/src/editor/raw_editor/boundaries/text/quill_editor_document_boundary.dart new file mode 100644 index 000000000..0754fce81 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/text/quill_editor_document_boundary.dart @@ -0,0 +1,23 @@ +import 'package:flutter/services.dart'; +import '../quill_editor_text_boundary.dart'; + +// The document boundary is unique and is a constant function of the input +// position. +class QuillEditorDocumentBoundary extends QuillEditorTextBoundary { + const QuillEditorDocumentBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + const TextPosition(offset: 0); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textEditingValue.text.length, + affinity: TextAffinity.upstream, + ); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/text/quill_editor_line_break_boundary.dart b/lib/src/editor/raw_editor/boundaries/text/quill_editor_line_break_boundary.dart new file mode 100644 index 000000000..12ac3e640 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/text/quill_editor_line_break_boundary.dart @@ -0,0 +1,29 @@ +import 'package:flutter/services.dart'; +import '../quill_editor_text_boundary.dart'; + +// The linebreaks of the current text layout. The input [TextPosition]s are +// interpreted as caret locations because [TextPainter.getLineAtOffset] is +// text-affinity-aware. +class QuillEditorLineBreak extends QuillEditorTextBoundary { + const QuillEditorLineBreak(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).start, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/text/quill_editor_whitespace_boundary.dart b/lib/src/editor/raw_editor/boundaries/text/quill_editor_whitespace_boundary.dart new file mode 100644 index 000000000..5a39f6d06 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/text/quill_editor_whitespace_boundary.dart @@ -0,0 +1,36 @@ +import 'package:flutter/services.dart'; +import '../quill_editor_text_boundary.dart'; + +// The word modifier generally removes the word boundaries around white spaces +// (and newlines), IOW white spaces and some other punctuations are considered +// a part of the next word in the search direction. +class QuillEditorWhitespaceBoundary extends QuillEditorTextBoundary { + const QuillEditorWhitespaceBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; index >= 0; index -= 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index); + } + } + return const TextPosition(offset: 0); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + for (var index = position.offset; + index < textEditingValue.text.length; + index += 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index + 1); + } + } + return TextPosition(offset: textEditingValue.text.length); + } +} diff --git a/lib/src/editor/raw_editor/boundaries/text/quill_editor_word_boundary.dart b/lib/src/editor/raw_editor/boundaries/text/quill_editor_word_boundary.dart new file mode 100644 index 000000000..4921322d6 --- /dev/null +++ b/lib/src/editor/raw_editor/boundaries/text/quill_editor_word_boundary.dart @@ -0,0 +1,30 @@ +import 'package:flutter/services.dart'; +import '../quill_editor_text_boundary.dart'; + +// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. +class QuillEditorWordBoundary extends QuillEditorTextBoundary { + const QuillEditorWordBoundary(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).start, + // Word boundary seems to always report downstream on many platforms. + affinity: TextAffinity.downstream, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).end, + // Word boundary seems to always report downstream on many platforms. + affinity: TextAffinity.downstream, + ); + } +} diff --git a/lib/src/editor/raw_editor/config/raw_editor_config.dart b/lib/src/editor/raw_editor/config/raw_editor_config.dart index 4c36417d0..0d26b20e2 100644 --- a/lib/src/editor/raw_editor/config/raw_editor_config.dart +++ b/lib/src/editor/raw_editor/config/raw_editor_config.dart @@ -7,11 +7,11 @@ import '../../../document/nodes/node.dart'; import '../../../editor/embed/embed_editor_builder.dart'; import '../../../editor/raw_editor/raw_editor.dart'; import '../../../editor/raw_editor/raw_editor_state.dart'; -import '../../../editor/widgets/cursor.dart'; -import '../../../editor/widgets/default_styles.dart'; import '../../../editor/widgets/delegate.dart'; import '../../../editor/widgets/link.dart'; +import '../../../editor/widgets/styles/default_styles.dart'; import '../../../toolbar/theme/quill_dialog_theme.dart'; +import '../../widgets/styles/cursor_style.dart'; import '../../widgets/text/utils/text_block_utils.dart'; import '../builders/leading_block_builder.dart'; import 'events/events.dart'; diff --git a/lib/src/editor/raw_editor/editor_glyph_heights.dart b/lib/src/editor/raw_editor/editor_glyph_heights.dart new file mode 100644 index 000000000..079bd190d --- /dev/null +++ b/lib/src/editor/raw_editor/editor_glyph_heights.dart @@ -0,0 +1,12 @@ +import 'package:meta/meta.dart'; + +@immutable +class QuillEditorGlyphHeights { + const QuillEditorGlyphHeights( + this.startGlyphHeight, + this.endGlyphHeight, + ); + + final double startGlyphHeight; + final double endGlyphHeight; +} diff --git a/lib/src/editor/raw_editor/editor_state.dart b/lib/src/editor/raw_editor/editor_state.dart new file mode 100644 index 000000000..7382b0dd6 --- /dev/null +++ b/lib/src/editor/raw_editor/editor_state.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import '../../common/structs/offset_value.dart'; +import '../render/render_editor.dart'; +import '../widgets/text/selection/text_selection.dart'; +import 'raw_editor.dart'; + +/// Base interface for the editor state which defines contract used by +/// various mixins. +abstract class EditorState extends State + implements TextSelectionDelegate { + ScrollController get scrollController; + + RenderEditor get renderEditor; + + EditorTextSelectionOverlay? get selectionOverlay; + + List get pasteStyleAndEmbed; + + String get pastePlainText; + + /// Controls the floating cursor animation when it is released. + /// The floating cursor is animated to merge with the regular cursor. + AnimationController get floatingCursorResetController; + + /// Returns true if the editor has been marked as needing to be rebuilt. + bool get dirty; + + bool showToolbar(); + + void requestKeyboard(); +} diff --git a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/editor/raw_editor/input/raw_editor_state_text_input_client_mixin.dart similarity index 99% rename from lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart rename to lib/src/editor/raw_editor/input/raw_editor_state_text_input_client_mixin.dart index dc9b0ba11..80d578f00 100644 --- a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/editor/raw_editor/input/raw_editor_state_text_input_client_mixin.dart @@ -7,10 +7,9 @@ import 'package:flutter/material.dart' show Theme; import 'package:flutter/scheduler.dart' show SchedulerBinding; import 'package:flutter/services.dart'; -import '../../delta/delta_diff.dart'; -import '../../document/document.dart'; -import '../editor.dart'; -import 'raw_editor.dart'; +import '../../../delta/delta_diff.dart'; +import '../../../document/document.dart'; +import '../editor_state.dart'; mixin RawEditorStateTextInputClientMixin on EditorState implements TextInputClient { diff --git a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart index e112e883e..480d3d07d 100644 --- a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart +++ b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart @@ -4,10 +4,10 @@ import '../../../document/attribute.dart'; import '../../../document/style.dart'; import '../../../toolbar/buttons/link_style2_button.dart'; import '../../../toolbar/buttons/search/search_dialog.dart'; -import '../../editor.dart'; +import '../../render/utils/quill_vertical_caret_movement.dart'; import '../../widgets/link.dart'; +import '../boundaries/editor_text_boundaries.dart'; import '../raw_editor_state.dart'; -import '../raw_editor_text_boundaries.dart'; // ------------------------------- Text Actions ------------------------------- class QuillEditorDeleteTextAction diff --git a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions_manager.dart b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions_manager.dart index bb03c2900..b81dea7bf 100644 --- a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions_manager.dart +++ b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions_manager.dart @@ -1,8 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; +import '../boundaries/editor_text_boundaries.dart'; import '../raw_editor_state.dart'; -import '../raw_editor_text_boundaries.dart'; import 'editor_keyboard_shortcut_actions.dart'; @internal @@ -48,7 +48,7 @@ class EditorKeyboardShortcutsActionsManager { final mixedBoundary = intent.forward ? QuillEditorMixedBoundary(atomicTextBoundary, boundary) : QuillEditorMixedBoundary(boundary, atomicTextBoundary); - // Use a _MixedBoundary to make sure we don't leave invalid codepoints in + // Use a QuillEditorMixedBoundary to make sure we don't leave invalid codepoints in // the field after deletion. return QuillEditorCollapsedSelectionBoundary(mixedBoundary, intent.forward); } @@ -99,6 +99,7 @@ class EditorKeyboardShortcutsActionsManager { context: context, defaultAction: defaultAction); } + // default actions definitions late final Action _updateSelectionAction = CallbackAction(onInvoke: _updateSelection); @@ -124,7 +125,8 @@ class EditorKeyboardShortcutsActionsManager { late final QuillEditorApplyCheckListAction _applyCheckListAction = QuillEditorApplyCheckListAction(rawEditorState); - late final Map> _actions = >{ + late final Map> actions = + Map>.unmodifiable({ DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), ReplaceTextIntent: _replaceTextAction, UpdateSelectionIntent: _updateSelectionAction, @@ -198,7 +200,5 @@ class EditorKeyboardShortcutsActionsManager { // Paging and scrolling ExtendSelectionVerticallyToAdjacentPageIntent: _adjacentPageAction, ScrollIntent: QuillEditorScrollAction(rawEditorState), - }; - - Map> get actions => _actions; + }); } diff --git a/lib/src/editor/raw_editor/quill_single_child_scroll_view.dart b/lib/src/editor/raw_editor/quill_single_child_scroll_view.dart deleted file mode 100644 index 69877c392..000000000 --- a/lib/src/editor/raw_editor/quill_single_child_scroll_view.dart +++ /dev/null @@ -1,362 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -/// Very similar to [SingleChildView] but with a [ViewportBuilder] argument -/// instead of a [Widget] -/// -/// Useful when child needs [ViewportOffset] ([RenderEditor] no longer uses this class!) -/// see: [SingleChildScrollView] -@Deprecated( - 'This class is no longer being used and will be removed in future versions. If you disagree, please open an issue.') -class QuillSingleChildScrollView extends StatelessWidget { - /// Creates a box in which a single widget can be scrolled. - const QuillSingleChildScrollView({ - required this.controller, - required this.viewportBuilder, - super.key, - this.physics, - this.restorationId, - }); - - /// An object that can be used to control the position to which this scroll - /// view is scrolled. - /// - /// Must be null if [primary] is true. - /// - /// A [ScrollController] serves several purposes. It can be used to control - /// the initial scroll position (see [ScrollController.initialScrollOffset]). - /// It can be used to control whether the scroll view should automatically - /// save and restore its scroll position in the [PageStorage] (see - /// [ScrollController.keepScrollOffset]). It can be used to read the current - /// scroll position (see [ScrollController.offset]), or change it (see - /// [ScrollController.animateTo]). - final ScrollController controller; - - /// How the scroll view should respond to user input. - /// - /// For example, determines how the scroll view continues to animate after the - /// user stops dragging the scroll view. - /// - /// Defaults to matching platform conventions. - final ScrollPhysics? physics; - - /// {@macro flutter.widgets.scrollable.restorationId} - final String? restorationId; - - final ViewportBuilder viewportBuilder; - - AxisDirection _getDirection(BuildContext context) { - return getAxisDirectionFromAxisReverseAndDirectionality( - context, - Axis.vertical, - false, - ); - } - - @override - Widget build(BuildContext context) { - final axisDirection = _getDirection(context); - final scrollController = controller; - final scrollable = Scrollable( - axisDirection: axisDirection, - controller: scrollController, - physics: physics, - restorationId: restorationId, - viewportBuilder: (context, offset) { - return _SingleChildViewport( - offset: offset, - child: viewportBuilder(context, offset), - ); - }, - ); - return scrollable; - } -} - -class _SingleChildViewport extends SingleChildRenderObjectWidget { - const _SingleChildViewport({ - required this.offset, - super.child, - }); - - final ViewportOffset offset; - - @override - _RenderSingleChildViewport createRenderObject(BuildContext context) { - return _RenderSingleChildViewport( - offset: offset, - ); - } - - @override - void updateRenderObject( - BuildContext context, _RenderSingleChildViewport renderObject) { - // Order dependency: The offset setter reads the axis direction. - renderObject.offset = offset; - } -} - -class _RenderSingleChildViewport extends RenderBox - with RenderObjectWithChildMixin - implements RenderAbstractViewport { - _RenderSingleChildViewport({ - required ViewportOffset offset, - double cacheExtent = RenderAbstractViewport.defaultCacheExtent, - RenderBox? child, - }) : _offset = offset, - _cacheExtent = cacheExtent { - this.child = child; - } - - ViewportOffset get offset => _offset; - ViewportOffset _offset; - - set offset(ViewportOffset value) { - if (value == _offset) return; - if (attached) _offset.removeListener(_hasScrolled); - _offset = value; - if (attached) _offset.addListener(_hasScrolled); - markNeedsLayout(); - } - - /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} - double get cacheExtent => _cacheExtent; - double _cacheExtent; - - set cacheExtent(double value) { - if (value == _cacheExtent) return; - _cacheExtent = value; - markNeedsLayout(); - } - - void _hasScrolled() { - markNeedsPaint(); - markNeedsSemanticsUpdate(); - } - - @override - void setupParentData(RenderObject child) { - // We don't actually use the offset argument in BoxParentData, so let's - // avoid allocating it at all. - if (child.parentData is! ParentData) child.parentData = ParentData(); - } - - @override - bool get isRepaintBoundary => true; - - double get _viewportExtent { - assert(hasSize); - return size.height; - } - - double get _minScrollExtent { - assert(hasSize); - return 0; - } - - double get _maxScrollExtent { - assert(hasSize); - if (child == null) return 0; - return math.max(0, child!.size.height - size.height); - } - - BoxConstraints _getInnerConstraints(BoxConstraints constraints) { - return constraints.widthConstraints(); - } - - @override - double computeMinIntrinsicWidth(double height) { - if (child != null) return child!.getMinIntrinsicWidth(height); - return 0; - } - - @override - double computeMaxIntrinsicWidth(double height) { - if (child != null) return child!.getMaxIntrinsicWidth(height); - return 0; - } - - @override - double computeMinIntrinsicHeight(double width) { - if (child != null) return child!.getMinIntrinsicHeight(width); - return 0; - } - - @override - double computeMaxIntrinsicHeight(double width) { - if (child != null) return child!.getMaxIntrinsicHeight(width); - return 0; - } - - // We don't override computeDistanceToActualBaseline(), because we - // want the default behavior (returning null). Otherwise, as you - // scroll, it would shift in its parent if the parent was baseline-aligned, - // which makes no sense. - - @override - Size computeDryLayout(BoxConstraints constraints) { - if (child == null) { - return constraints.smallest; - } - final childSize = child!.getDryLayout(_getInnerConstraints(constraints)); - return constraints.constrain(childSize); - } - - @override - void performLayout() { - final constraints = this.constraints; - if (child == null) { - size = constraints.smallest; - } else { - child!.layout(_getInnerConstraints(constraints), parentUsesSize: true); - size = constraints.constrain(child!.size); - } - - offset - ..applyViewportDimension(_viewportExtent) - ..applyContentDimensions(_minScrollExtent, _maxScrollExtent); - } - - Offset get _paintOffset => _paintOffsetForPosition(offset.pixels); - - Offset _paintOffsetForPosition(double position) { - return Offset(0, -position); - } - - bool _shouldClipAtPaintOffset(Offset paintOffset) { - assert(child != null); - return paintOffset.dx < 0 || - paintOffset.dy < 0 || - paintOffset.dx + child!.size.width > size.width || - paintOffset.dy + child!.size.height > size.height; - } - - @override - void paint(PaintingContext context, Offset offset) { - if (child != null) { - final paintOffset = _paintOffset; - - void paintContents(PaintingContext context, Offset offset) { - context.paintChild(child!, offset + paintOffset); - } - - if (_shouldClipAtPaintOffset(paintOffset)) { - _clipRectLayer.layer = context.pushClipRect( - needsCompositing, - offset, - Offset.zero & size, - paintContents, - oldLayer: _clipRectLayer.layer, - ); - } else { - _clipRectLayer.layer = null; - paintContents(context, offset); - } - } - } - - final _clipRectLayer = LayerHandle(); - - @override - void applyPaintTransform(RenderBox child, Matrix4 transform) { - final paintOffset = _paintOffset; - transform.translate(paintOffset.dx, paintOffset.dy); - } - - @override - Rect? describeApproximatePaintClip(RenderObject? child) { - if (child != null && _shouldClipAtPaintOffset(_paintOffset)) { - return Offset.zero & size; - } - return null; - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - if (child != null) { - return result.addWithPaintOffset( - offset: _paintOffset, - position: position, - hitTest: (result, transformed) { - assert(transformed == position + -_paintOffset); - return child!.hitTest(result, position: transformed); - }, - ); - } - return false; - } - - @override - RevealedOffset getOffsetToReveal( - RenderObject target, - double alignment, { - Rect? rect, - Axis? axis, // Unused, only Axis.vertical supported by this viewport. - }) { - rect ??= target.paintBounds; - if (target is! RenderBox) { - return RevealedOffset(offset: offset.pixels, rect: rect); - } - - final targetBox = target; - final transform = targetBox.getTransformTo(child); - final bounds = MatrixUtils.transformRect(transform, rect); - - final double leadingScrollOffset; - final double targetMainAxisExtent; - final double mainAxisExtent; - - mainAxisExtent = size.height; - leadingScrollOffset = bounds.top; - targetMainAxisExtent = bounds.height; - - final targetOffset = leadingScrollOffset - - (mainAxisExtent - targetMainAxisExtent) * alignment; - final targetRect = bounds.shift(_paintOffsetForPosition(targetOffset)); - return RevealedOffset(offset: targetOffset, rect: targetRect); - } - - @override - void showOnScreen({ - RenderObject? descendant, - Rect? rect, - Duration duration = Duration.zero, - Curve curve = Curves.ease, - }) { - if (!offset.allowImplicitScrolling) { - return super.showOnScreen( - descendant: descendant, - rect: rect, - duration: duration, - curve: curve, - ); - } - - final newRect = RenderViewportBase.showInViewport( - descendant: descendant, - viewport: this, - offset: offset, - rect: rect, - duration: duration, - curve: curve, - ); - super.showOnScreen( - rect: newRect, - duration: duration, - curve: curve, - ); - } - - @override - Rect describeSemanticsClip(RenderObject child) { - return Rect.fromLTRB( - semanticBounds.left, - semanticBounds.top - cacheExtent, - semanticBounds.right, - semanticBounds.bottom + cacheExtent, - ); - } -} diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index 4cc9543f3..206197b7e 100644 --- a/lib/src/editor/raw_editor/raw_editor.dart +++ b/lib/src/editor/raw_editor/raw_editor.dart @@ -1,9 +1,6 @@ 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 'config/raw_editor_config.dart'; import 'raw_editor_state.dart'; @@ -40,40 +37,3 @@ typedef QuillEditorContextMenuBuilder = Widget Function( BuildContext context, QuillRawEditorState rawEditorState, ); - -@immutable -class QuillEditorGlyphHeights { - const QuillEditorGlyphHeights( - this.startGlyphHeight, - this.endGlyphHeight, - ); - - final double startGlyphHeight; - final double endGlyphHeight; -} - -/// Base interface for the editor state which defines contract used by -/// various mixins. -abstract class EditorState extends State - implements TextSelectionDelegate { - ScrollController get scrollController; - - RenderEditor get renderEditor; - - EditorTextSelectionOverlay? get selectionOverlay; - - List get pasteStyleAndEmbed; - - String get pastePlainText; - - /// Controls the floating cursor animation when it is released. - /// The floating cursor is animated to merge with the regular cursor. - AnimationController get floatingCursorResetController; - - /// Returns true if the editor has been marked as needing to be rebuilt. - bool get dirty; - - bool showToolbar(); - - void requestKeyboard(); -} diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index c6bcfa4a7..825ddab36 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -24,20 +24,24 @@ import '../../document/nodes/block.dart'; import '../../document/nodes/line.dart'; import '../../document/nodes/node.dart'; import '../editor.dart'; +import '../render/render_editor.dart'; import '../widgets/cursor.dart'; -import '../widgets/default_styles.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/proxies/baseline_proxy.dart'; +import '../widgets/styles/default_styles.dart'; +import '../widgets/text/block/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 'editor_glyph_heights.dart'; +import 'editor_state.dart'; +import 'input/raw_editor_state_text_input_client_mixin.dart'; import 'keyboard_shortcuts/editor_keyboard_shortcut_actions_manager.dart'; import 'keyboard_shortcuts/editor_keyboard_shortcuts.dart'; import 'raw_editor.dart'; -import 'raw_editor_render_object.dart'; -import 'raw_editor_state_selection_delegate_mixin.dart'; -import 'raw_editor_state_text_input_client_mixin.dart'; -import 'scribble_focusable.dart'; +import 'render/raw_editor_render_object.dart'; +import 'render/scribble_focusable.dart'; +import 'selection/raw_editor_state_selection_delegate_mixin.dart'; class QuillRawEditorState extends EditorState with diff --git a/lib/src/editor/raw_editor/raw_editor_text_boundaries.dart b/lib/src/editor/raw_editor/raw_editor_text_boundaries.dart deleted file mode 100644 index f15fd8e00..000000000 --- a/lib/src/editor/raw_editor/raw_editor_text_boundaries.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show TextLayoutMetrics; - -/// An interface for retrieving the logical text boundary -/// (left-closed-right-open) -/// at a given location in a document. -/// -/// Depending on the implementation of the [QuillEditorTextBoundary], the input -/// [TextPosition] can either point to a code unit, or a position between 2 code -/// units (which can be visually represented by the caret if the selection were -/// to collapse to that position). -/// -/// For example, [QuillEditorLineBreak] interprets the input [TextPosition] as a caret -/// location, since in Flutter the caret is generally painted between the -/// character the [TextPosition] points to and its previous character, and -/// [QuillEditorLineBreak] cares about the affinity of the input [TextPosition]. Most -/// other text boundaries however, interpret the input [TextPosition] as the -/// location of a code unit in the document, since it's easier to reason about -/// the text boundary given a code unit in the text. -/// -/// To convert a "code-unit-based" [QuillEditorTextBoundary] to "caret-location-based", -/// use the [QuillEditorCollapsedSelectionBoundary] combinator. -abstract class QuillEditorTextBoundary { - const QuillEditorTextBoundary(); - - TextEditingValue get textEditingValue; - - /// Returns the leading text boundary at the given location, inclusive. - TextPosition getLeadingTextBoundaryAt(TextPosition position); - - /// Returns the trailing text boundary at the given location, exclusive. - TextPosition getTrailingTextBoundaryAt(TextPosition position); - - TextRange getTextBoundaryAt(TextPosition position) { - return TextRange( - start: getLeadingTextBoundaryAt(position).offset, - end: getTrailingTextBoundaryAt(position).offset, - ); - } -} - -// ----------------------------- Text Boundaries ----------------------------- - -// The word modifier generally removes the word boundaries around white spaces -// (and newlines), IOW white spaces and some other punctuations are considered -// a part of the next word in the search direction. -class QuillEditorWhitespaceBoundary extends QuillEditorTextBoundary { - const QuillEditorWhitespaceBoundary(this.textEditingValue); - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - for (var index = position.offset; index >= 0; index -= 1) { - if (!TextLayoutMetrics.isWhitespace( - textEditingValue.text.codeUnitAt(index))) { - return TextPosition(offset: index); - } - } - return const TextPosition(offset: 0); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - for (var index = position.offset; - index < textEditingValue.text.length; - index += 1) { - if (!TextLayoutMetrics.isWhitespace( - textEditingValue.text.codeUnitAt(index))) { - return TextPosition(offset: index + 1); - } - } - return TextPosition(offset: textEditingValue.text.length); - } -} - -// Most apps delete the entire grapheme when the backspace key is pressed. -// Also always put the new caret location to character boundaries to avoid -// sending malformed UTF-16 code units to the paragraph builder. -class QuillEditorCharacterBoundary extends QuillEditorTextBoundary { - const QuillEditorCharacterBoundary(this.textEditingValue); - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - final int endOffset = - math.min(position.offset + 1, textEditingValue.text.length); - return TextPosition( - offset: - CharacterRange.at(textEditingValue.text, position.offset, endOffset) - .stringBeforeLength, - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - final int endOffset = - math.min(position.offset + 1, textEditingValue.text.length); - final range = - CharacterRange.at(textEditingValue.text, position.offset, endOffset); - return TextPosition( - offset: textEditingValue.text.length - range.stringAfterLength, - ); - } - - @override - TextRange getTextBoundaryAt(TextPosition position) { - final int endOffset = - math.min(position.offset + 1, textEditingValue.text.length); - final range = - CharacterRange.at(textEditingValue.text, position.offset, endOffset); - return TextRange( - start: range.stringBeforeLength, - end: textEditingValue.text.length - range.stringAfterLength, - ); - } -} - -// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. -class QuillEditorWordBoundary extends QuillEditorTextBoundary { - const QuillEditorWordBoundary(this.textLayout, this.textEditingValue); - - final TextLayoutMetrics textLayout; - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getWordBoundary(position).start, - // Word boundary seems to always report downstream on many platforms. - affinity: TextAffinity.downstream, - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getWordBoundary(position).end, - // Word boundary seems to always report downstream on many platforms. - affinity: TextAffinity.downstream, - ); - } -} - -// The linebreaks of the current text layout. The input [TextPosition]s are -// interpreted as caret locations because [TextPainter.getLineAtOffset] is -// text-affinity-aware. -class QuillEditorLineBreak extends QuillEditorTextBoundary { - const QuillEditorLineBreak(this.textLayout, this.textEditingValue); - - final TextLayoutMetrics textLayout; - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getLineAtOffset(position).start, - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textLayout.getLineAtOffset(position).end, - affinity: TextAffinity.upstream, - ); - } -} - -// The document boundary is unique and is a constant function of the input -// position. -class QuillEditorDocumentBoundary extends QuillEditorTextBoundary { - const QuillEditorDocumentBoundary(this.textEditingValue); - - @override - final TextEditingValue textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) => - const TextPosition(offset: 0); - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return TextPosition( - offset: textEditingValue.text.length, - affinity: TextAffinity.upstream, - ); - } -} - -// ------------------------ Text Boundary Combinators ------------------------ - -// Expands the innerTextBoundary with outerTextBoundary. -class QuillEditorExpandedTextBoundary extends QuillEditorTextBoundary { - QuillEditorExpandedTextBoundary( - this.innerTextBoundary, this.outerTextBoundary); - - final QuillEditorTextBoundary innerTextBoundary; - final QuillEditorTextBoundary outerTextBoundary; - - @override - TextEditingValue get textEditingValue { - assert(innerTextBoundary.textEditingValue == - outerTextBoundary.textEditingValue); - return innerTextBoundary.textEditingValue; - } - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return outerTextBoundary.getLeadingTextBoundaryAt( - innerTextBoundary.getLeadingTextBoundaryAt(position), - ); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return outerTextBoundary.getTrailingTextBoundaryAt( - innerTextBoundary.getTrailingTextBoundaryAt(position), - ); - } -} - -// Force the innerTextBoundary to interpret the input [TextPosition]s as caret -// locations instead of code unit positions. -// -// The innerTextBoundary must be a [_TextBoundary] that interprets the input -// [TextPosition]s as code unit positions. -class QuillEditorCollapsedSelectionBoundary extends QuillEditorTextBoundary { - QuillEditorCollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); - - final QuillEditorTextBoundary innerTextBoundary; - final bool isForward; - - @override - TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) { - return isForward - ? innerTextBoundary.getLeadingTextBoundaryAt(position) - : position.offset <= 0 - ? const TextPosition(offset: 0) - : innerTextBoundary.getLeadingTextBoundaryAt( - TextPosition(offset: position.offset - 1)); - } - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) { - return isForward - ? innerTextBoundary.getTrailingTextBoundaryAt(position) - : position.offset <= 0 - ? const TextPosition(offset: 0) - : innerTextBoundary.getTrailingTextBoundaryAt( - TextPosition(offset: position.offset - 1)); - } -} - -// A _TextBoundary that creates a [TextRange] where its start is from the -// specified leading text boundary and its end is from the specified trailing -// text boundary. -class QuillEditorMixedBoundary extends QuillEditorTextBoundary { - QuillEditorMixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); - - final QuillEditorTextBoundary leadingTextBoundary; - final QuillEditorTextBoundary trailingTextBoundary; - - @override - TextEditingValue get textEditingValue { - assert(leadingTextBoundary.textEditingValue == - trailingTextBoundary.textEditingValue); - return leadingTextBoundary.textEditingValue; - } - - @override - TextPosition getLeadingTextBoundaryAt(TextPosition position) => - leadingTextBoundary.getLeadingTextBoundaryAt(position); - - @override - TextPosition getTrailingTextBoundaryAt(TextPosition position) => - trailingTextBoundary.getTrailingTextBoundaryAt(position); -} diff --git a/lib/src/editor/raw_editor/raw_editor_render_object.dart b/lib/src/editor/raw_editor/render/raw_editor_render_object.dart similarity index 93% rename from lib/src/editor/raw_editor/raw_editor_render_object.dart rename to lib/src/editor/raw_editor/render/raw_editor_render_object.dart index d79536210..551576480 100644 --- a/lib/src/editor/raw_editor/raw_editor_render_object.dart +++ b/lib/src/editor/raw_editor/render/raw_editor_render_object.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ViewportOffset; -import '../../document/document.dart'; -import '../editor.dart'; -import '../widgets/cursor.dart'; +import '../../../document/document.dart'; +import '../../render/render_editor.dart'; +import '../../render/utils/render_editor_utils.dart'; +import '../../widgets/cursor.dart'; class QuillRawEditorMultiChildRenderObject extends MultiChildRenderObjectWidget { diff --git a/lib/src/editor/raw_editor/scribble_focusable.dart b/lib/src/editor/raw_editor/render/scribble_focusable.dart similarity index 100% rename from lib/src/editor/raw_editor/scribble_focusable.dart rename to lib/src/editor/raw_editor/render/scribble_focusable.dart diff --git a/lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/editor/raw_editor/selection/raw_editor_state_selection_delegate_mixin.dart similarity index 97% rename from lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart rename to lib/src/editor/raw_editor/selection/raw_editor_state_selection_delegate_mixin.dart index 9746711a5..acd3d05a8 100644 --- a/lib/src/editor/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/src/editor/raw_editor/selection/raw_editor_state_selection_delegate_mixin.dart @@ -3,9 +3,9 @@ import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -import '../../delta/delta_diff.dart'; -import '../../document/document.dart'; -import 'raw_editor.dart'; +import '../../../delta/delta_diff.dart'; +import '../../../document/document.dart'; +import '../editor_state.dart'; mixin RawEditorStateSelectionDelegateMixin on EditorState implements TextSelectionDelegate { diff --git a/lib/src/editor/render/abstract_render_editor.dart b/lib/src/editor/render/abstract_render_editor.dart new file mode 100644 index 000000000..5ba2523c6 --- /dev/null +++ b/lib/src/editor/render/abstract_render_editor.dart @@ -0,0 +1,94 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Base interface for editable render objects. +abstract class RenderAbstractEditor implements TextLayoutMetrics { + TextSelection selectWordAtPosition(TextPosition position); + + TextSelection selectLineAtPosition(TextPosition position); + + /// Returns preferred line height at specified `position` in text. + double preferredLineHeight(TextPosition position); + + /// Returns [Rect] for caret in local coordinates + /// + /// Useful to enforce visibility of full caret at given position + Rect getLocalRectForCaret(TextPosition position); + + /// Returns the local coordinates of the endpoints of the given selection. + /// + /// If the selection is collapsed (and therefore occupies a single point), the + /// returned list is of length one. Otherwise, the selection is not collapsed + /// and the returned list is of length two. In this case, however, the two + /// points might actually be co-located (e.g., because of a bidirectional + /// selection that contains some text but whose ends meet in the middle). + TextPosition getPositionForOffset(Offset offset); + + /// Returns the local coordinates of the endpoints of the given selection. + /// + /// If the selection is collapsed (and therefore occupies a single point), the + /// returned list is of length one. Otherwise, the selection is not collapsed + /// and the returned list is of length two. In this case, however, the two + /// points might actually be co-located (e.g., because of a bidirectional + /// selection that contains some text but whose ends meet in the middle). + List getEndpointsForSelection( + TextSelection textSelection); + + /// Sets the screen position of the floating cursor and the text position + /// closest to the cursor. + /// `resetLerpValue` drives the size of the floating cursor. + /// See [EditorState.floatingCursorResetController]. + void setFloatingCursor(FloatingCursorDragState dragState, + Offset lastBoundedOffset, TextPosition lastTextPosition, + {double? resetLerpValue}); + + /// If [ignorePointer] is false (the default) then this method is called by + /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] + /// callback. + /// + /// When [ignorePointer] is true, an ancestor widget must respond to tap + /// down events by calling this method. + void handleTapDown(TapDownDetails details); + + /// Selects the set words of a paragraph in a given range of global positions. + /// + /// The first and last endpoints of the selection will always be at the + /// beginning and end of a word respectively. + /// + /// {@macro flutter.rendering.editable.select} + void selectWordsInRange( + Offset from, + Offset to, + SelectionChangedCause cause, + ); + + /// Move the selection to the beginning or end of a word. + /// + /// {@macro flutter.rendering.editable.select} + void selectWordEdge(SelectionChangedCause cause); + + /// + /// Returns the new selection. Note that the returned value may not be + /// yet reflected in the latest widget state. + /// + /// Returns null if no change occurred. + TextSelection? selectPositionAt( + {required Offset from, required SelectionChangedCause cause, Offset? to}); + + /// Select a word around the location of the last tap down. + /// + /// {@macro flutter.rendering.editable.select} + void selectWord(SelectionChangedCause cause); + + /// Move selection to the location of the last tap down. + /// + /// {@template flutter.rendering.editable.select} + /// This method is mainly used to translate user inputs in global positions + /// into a [TextSelection]. When used in conjunction with a [EditableText], + /// the selection change is fed back into [TextEditingController.selection]. + /// + /// If you have a [TextEditingController], it's generally easier to + /// programmatically manipulate its `value` or `selection` directly. + /// {@endtemplate} + void selectPosition({required SelectionChangedCause cause}); +} diff --git a/lib/src/editor/render/quill_editor_text_selection_gestures.dart b/lib/src/editor/render/quill_editor_text_selection_gestures.dart new file mode 100644 index 000000000..4f3fc5430 --- /dev/null +++ b/lib/src/editor/render/quill_editor_text_selection_gestures.dart @@ -0,0 +1,230 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; +import '../../../../internal.dart'; +import '../../document/document.dart'; +import '../editor.dart'; +import '../raw_editor/editor_state.dart'; +import '../raw_editor/raw_editor.dart'; +import '../widgets/delegate.dart'; + +@internal +class QuillEditorSelectionGestureDetectorBuilder + extends EditorTextSelectionGestureDetectorBuilder { + QuillEditorSelectionGestureDetectorBuilder( + this._state, + this._detectWordBoundary, + ) : super(delegate: _state, detectWordBoundary: _detectWordBoundary); + + final QuillEditorState _state; + final bool _detectWordBoundary; + + @override + void onForcePressStart(ForcePressDetails details) { + super.onForcePressStart(details); + if (delegate.selectionEnabled && shouldShowSelectionToolbar) { + editor!.showToolbar(); + } + } + + @override + void onForcePressEnd(ForcePressDetails details) {} + + @override + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (_state.configurations.onSingleLongTapMoveUpdate != null) { + if (renderEditor != null && + _state.configurations.onSingleLongTapMoveUpdate!( + details, + renderEditor!.getPositionForOffset, + )) { + return; + } + } + if (!delegate.selectionEnabled) { + return; + } + + if (Theme.of(_state.context).isCupertino) { + renderEditor!.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } else { + renderEditor!.selectWordsInRange( + details.globalPosition - details.offsetFromOrigin, + details.globalPosition, + SelectionChangedCause.longPress, + ); + } + } + + bool _isPositionSelected(TapUpDetails details) { + if (_state.controller.document.isEmpty()) { + return false; + } + final pos = renderEditor!.getPositionForOffset(details.globalPosition); + final result = + editor!.widget.controller.document.querySegmentLeafNode(pos.offset); + final line = result.line; + if (line == null) { + return false; + } + final segmentLeaf = result.leaf; + if (segmentLeaf == null && line.length == 1) { + editor!.widget.controller.updateSelection( + TextSelection.collapsed(offset: pos.offset), + ChangeSource.local, + ); + return true; + } + return false; + } + + @override + void onTapDown(TapDownDetails details) { + if (_state.configurations.onTapDown != null) { + if (renderEditor != null && + _state.configurations.onTapDown!( + details, + renderEditor!.getPositionForOffset, + )) { + return; + } + } + super.onTapDown(details); + } + + bool isShiftClick(PointerDeviceKind deviceKind) { + final pressed = HardwareKeyboard.instance.logicalKeysPressed; + return deviceKind == PointerDeviceKind.mouse && + (pressed.contains(LogicalKeyboardKey.shiftLeft) || + pressed.contains(LogicalKeyboardKey.shiftRight)); + } + + @override + void onSingleTapUp(TapUpDetails details) { + if (_state.configurations.onTapUp != null && + renderEditor != null && + _state.configurations.onTapUp!( + details, + renderEditor!.getPositionForOffset, + )) { + return; + } + + editor!.hideToolbar(); + + try { + if (delegate.selectionEnabled && !_isPositionSelected(details)) { + if (isAppleOS || isDesktop) { + // added isDesktop() to enable extend selection in Windows platform + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + // Precise devices should place the cursor at a precise position. + // If `Shift` key is pressed then + // extend current selection instead. + if (isShiftClick(details.kind)) { + renderEditor! + ..extendSelection(details.globalPosition, + cause: SelectionChangedCause.tap) + ..onSelectionCompleted(); + } else { + renderEditor! + ..selectPosition(cause: SelectionChangedCause.tap) + ..onSelectionCompleted(); + } + + break; + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // On macOS/iOS/iPadOS a touch tap places the cursor at the edge + // of the word. + if (_detectWordBoundary) { + renderEditor! + ..selectWordEdge(SelectionChangedCause.tap) + ..onSelectionCompleted(); + } else { + renderEditor! + ..selectPosition(cause: SelectionChangedCause.tap) + ..onSelectionCompleted(); + } + break; + case PointerDeviceKind.trackpad: + // TODO: Handle this case. + break; + } + } else { + renderEditor! + ..selectPosition(cause: SelectionChangedCause.tap) + ..onSelectionCompleted(); + } + } + } finally { + _requestKeyboard(); + } + } + + @override + void onSingleLongTapStart(LongPressStartDetails details) { + if (_state.configurations.onSingleLongTapStart != null) { + if (renderEditor != null && + _state.configurations.onSingleLongTapStart!( + details, + renderEditor!.getPositionForOffset, + )) { + return; + } + } + + if (delegate.selectionEnabled) { + if (Theme.of(_state.context).isCupertino) { + renderEditor!.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } else { + renderEditor!.selectWord(SelectionChangedCause.longPress); + Feedback.forLongPress(_state.context); + } + } + } + + @override + void onSingleLongTapEnd(LongPressEndDetails details) { + if (_state.configurations.onSingleLongTapEnd != null) { + if (renderEditor != null) { + if (_state.configurations.onSingleLongTapEnd!( + details, + renderEditor!.getPositionForOffset, + )) { + return; + } + + if (delegate.selectionEnabled) { + renderEditor!.onSelectionCompleted(); + } + } + } + super.onSingleLongTapEnd(details); + } + + /// Throws [StateError] if [_editorKey] is not connected to [QuillRawEditor] correctly. + /// + /// See also: [Flutter currentState docs](https://github.com/flutter/flutter/blob/b8211b3d941f2dcaa2db22e4572b74ede620cced/packages/flutter/lib/src/widgets/framework.dart#L179-L181) + EditorState get _requireEditorCurrentState { + final currentState = delegate.editableTextKey.currentState; + if (currentState == null) { + throw StateError( + 'The $EditorState is null, ensure the ${delegate.editableTextKey} is associated correctly with $QuillRawEditor.'); + } + return currentState; + } + + void _requestKeyboard() { + _requireEditorCurrentState.requestKeyboard(); + } +} diff --git a/lib/src/editor/render/render_editable_container_box.dart b/lib/src/editor/render/render_editable_container_box.dart new file mode 100644 index 000000000..5d751efd5 --- /dev/null +++ b/lib/src/editor/render/render_editable_container_box.dart @@ -0,0 +1,239 @@ +import 'dart:math' as math; +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; + +import '../../document/nodes/container.dart' as container_node; +import '../widgets/box.dart'; + +class EditableContainerParentData + extends ContainerBoxParentData {} + +/// Multi-child render box of editable content. +/// +/// Common ancestor for [RenderEditor] and [RenderEditableTextBlock]. +class RenderEditableContainerBox extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + RenderEditableContainerBox({ + required this.container, + required this.textDirection, + required this.scrollBottomInset, + required EdgeInsetsGeometry padding, + List? children, + }) : assert(padding.isNonNegative), + _padding = padding { + addAll(children); + } + + @protected + container_node.QuillContainer container; + TextDirection textDirection; + EdgeInsetsGeometry _padding; + double scrollBottomInset; + EdgeInsets? _resolvedPadding; + + void setContainer(container_node.QuillContainer c) { + if (container == c) { + return; + } + container = c; + markNeedsLayout(); + } + + EdgeInsetsGeometry getPadding() => _padding; + + void setPadding(EdgeInsetsGeometry value) { + assert(value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedsPaddingResolution(); + } + + EdgeInsets? get resolvedPadding => _resolvedPadding; + + void resolvePadding() { + if (_resolvedPadding != null) { + return; + } + _resolvedPadding = _padding.resolve(textDirection); + _resolvedPadding = _resolvedPadding!.copyWith(left: _resolvedPadding!.left); + + assert(_resolvedPadding!.isNonNegative); + } + + RenderEditableBox childAtPosition(TextPosition position) { + assert(firstChild != null); + final targetNode = container.queryChild(position.offset, false).node; + + var targetChild = firstChild; + while (targetChild != null) { + if (targetChild.container == targetNode) { + break; + } + final newChild = childAfter(targetChild); + if (newChild == null) { + // At start of document fails to find the position + targetChild = childAtOffset(const Offset(0, 0)); + break; + } + targetChild = newChild; + } + if (targetChild == null) { + throw 'targetChild should not be null'; + } + return targetChild; + } + + void _markNeedsPaddingResolution() { + _resolvedPadding = null; + markNeedsLayout(); + } + + /// Returns child of this container located at the specified local `offset`. + /// + /// If `offset` is above this container (offset.dy is negative) returns + /// the first child. Likewise, if `offset` is below this container then + /// returns the last child. + RenderEditableBox childAtOffset(Offset offset) { + assert(firstChild != null); + resolvePadding(); + + if (offset.dy <= _resolvedPadding!.top) { + return firstChild!; + } + if (offset.dy >= size.height - _resolvedPadding!.bottom) { + return lastChild!; + } + + var child = firstChild; + final dx = -offset.dx; + var dy = _resolvedPadding!.top; + while (child != null) { + if (child.size.contains(offset.translate(dx, -dy))) { + return child; + } + dy += child.size.height; + child = childAfter(child); + } + + // this case possible, when editor not scrollable, + // but minHeight > content height and tap was under content + return lastChild!; + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is EditableContainerParentData) { + return; + } + + child.parentData = EditableContainerParentData(); + } + + @override + void performLayout() { + assert(constraints.hasBoundedWidth); + resolvePadding(); + assert(_resolvedPadding != null); + + var mainAxisExtent = _resolvedPadding!.top; + var child = firstChild; + final innerConstraints = + BoxConstraints.tightFor(width: constraints.maxWidth) + .deflate(_resolvedPadding!); + while (child != null) { + child.layout(innerConstraints, parentUsesSize: true); + final childParentData = (child.parentData as EditableContainerParentData) + ..offset = Offset(_resolvedPadding!.left, mainAxisExtent); + mainAxisExtent += child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + mainAxisExtent += _resolvedPadding!.bottom; + size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); + + assert(size.isFinite); + } + + double _getIntrinsicCrossAxis(double Function(RenderBox child) childSize) { + var extent = 0.0; + var child = firstChild; + while (child != null) { + extent = math.max(extent, childSize(child)); + final childParentData = child.parentData as EditableContainerParentData; + child = childParentData.nextSibling; + } + return extent; + } + + double _getIntrinsicMainAxis(double Function(RenderBox child) childSize) { + var extent = 0.0; + var child = firstChild; + while (child != null) { + extent += childSize(child); + final childParentData = child.parentData as EditableContainerParentData; + child = childParentData.nextSibling; + } + return extent; + } + + @override + double computeMinIntrinsicWidth(double height) { + resolvePadding(); + return _getIntrinsicCrossAxis((child) { + final childHeight = math.max( + 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); + return child.getMinIntrinsicWidth(childHeight) + + _resolvedPadding!.left + + _resolvedPadding!.right; + }); + } + + @override + double computeMaxIntrinsicWidth(double height) { + resolvePadding(); + return _getIntrinsicCrossAxis((child) { + final childHeight = math.max( + 0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); + return child.getMaxIntrinsicWidth(childHeight) + + _resolvedPadding!.left + + _resolvedPadding!.right; + }); + } + + @override + double computeMinIntrinsicHeight(double width) { + resolvePadding(); + return _getIntrinsicMainAxis((child) { + final childWidth = math.max( + 0, width - _resolvedPadding!.left + _resolvedPadding!.right); + return child.getMinIntrinsicHeight(childWidth) + + _resolvedPadding!.top + + _resolvedPadding!.bottom; + }); + } + + @override + double computeMaxIntrinsicHeight(double width) { + resolvePadding(); + return _getIntrinsicMainAxis((child) { + final childWidth = math.max( + 0, width - _resolvedPadding!.left + _resolvedPadding!.right); + return child.getMaxIntrinsicHeight(childWidth) + + _resolvedPadding!.top + + _resolvedPadding!.bottom; + }); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + resolvePadding(); + return defaultComputeDistanceToFirstActualBaseline(baseline)! + + _resolvedPadding!.top; + } +} diff --git a/lib/src/editor/render/render_editor.dart b/lib/src/editor/render/render_editor.dart new file mode 100644 index 000000000..3828de6a4 --- /dev/null +++ b/lib/src/editor/render/render_editor.dart @@ -0,0 +1,906 @@ +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import '../../document/attribute.dart'; +import '../../document/document.dart'; +import '../widgets/box.dart'; +import '../widgets/cursor.dart'; +import '../widgets/painters/floating_cursor_painter.dart'; +import '../widgets/text/selection/text_selection.dart'; +import 'abstract_render_editor.dart'; +import 'render_editable_container_box.dart'; +import 'utils/quill_vertical_caret_movement.dart'; +import 'utils/render_editor_utils.dart'; + +/// Displays a document as a vertical list of document segments (lines +/// and blocks). +/// +/// Children of [RenderEditor] must be instances of [RenderEditableBox]. +class RenderEditor extends RenderEditableContainerBox + with RelayoutWhenSystemFontsChangeMixin + implements RenderAbstractEditor { + RenderEditor({ + required this.document, + required super.textDirection, + required bool hasFocus, + required this.selection, + required this.scrollable, + required LayerLink startHandleLayerLink, + required LayerLink endHandleLayerLink, + required super.padding, + required CursorCont cursorController, + required this.onSelectionChanged, + required this.onSelectionCompleted, + required super.scrollBottomInset, + required this.floatingCursorDisabled, + ViewportOffset? offset, + super.children, + EdgeInsets floatingCursorAddedMargin = + const EdgeInsets.fromLTRB(4, 4, 4, 5), + double? maxContentWidth, + }) : _hasFocus = hasFocus, + _extendSelectionOrigin = selection, + _startHandleLayerLink = startHandleLayerLink, + _endHandleLayerLink = endHandleLayerLink, + _cursorController = cursorController, + _maxContentWidth = maxContentWidth, + super( + container: document.root, + ); + + final CursorCont _cursorController; + final bool floatingCursorDisabled; + final bool scrollable; + + Document document; + TextSelection selection; + bool _hasFocus = false; + LayerLink _startHandleLayerLink; + LayerLink _endHandleLayerLink; + + /// Called when the selection changes. + TextSelectionChangedHandler onSelectionChanged; + TextSelectionCompletedHandler onSelectionCompleted; + final ValueNotifier _selectionStartInViewport = + ValueNotifier(true); + + ValueListenable get selectionStartInViewport => + _selectionStartInViewport; + + ValueListenable get selectionEndInViewport => _selectionEndInViewport; + final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + + void _updateSelectionExtentsVisibility(Offset effectiveOffset) { + final visibleRegion = Offset.zero & size; + final startPosition = + TextPosition(offset: selection.start, affinity: selection.affinity); + final startOffset = _getOffsetForCaret(startPosition); + // TODO(justinmc): https://github.com/flutter/flutter/issues/31495 + // Check if the selection is visible with an approximation because a + // difference between rounded and unrounded values causes the caret to be + // reported as having a slightly (< 0.5) negative y offset. This rounding + // happens in paragraph.cc's layout and TextPainer's + // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and + // this can be changed to be a strict check instead of an approximation. + const visibleRegionSlop = 0.5; + _selectionStartInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(startOffset + effectiveOffset); + + final endPosition = + TextPosition(offset: selection.end, affinity: selection.affinity); + final endOffset = _getOffsetForCaret(endPosition); + _selectionEndInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(endOffset + effectiveOffset); + } + + // returns offset relative to this at which the caret will be painted + // given a global TextPosition + Offset _getOffsetForCaret(TextPosition position) { + final child = childAtPosition(position); + final childPosition = child.globalToLocalPosition(position); + final boxParentData = child.parentData as BoxParentData; + final localOffsetForCaret = child.getOffsetForCaret(childPosition); + return boxParentData.offset + localOffsetForCaret; + } + + void setDocument(Document doc) { + if (document == doc) { + return; + } + document = doc; + markNeedsLayout(); + } + + void setHasFocus(bool h) { + if (_hasFocus == h) { + return; + } + _hasFocus = h; + markNeedsSemanticsUpdate(); + } + + Offset get _paintOffset => Offset(0, -(offset?.pixels ?? 0.0)); + + ViewportOffset? get offset => _offset; + ViewportOffset? _offset; + + set offset(ViewportOffset? value) { + if (_offset == value) return; + if (attached) _offset?.removeListener(markNeedsPaint); + _offset = value; + if (attached) _offset?.addListener(markNeedsPaint); + markNeedsLayout(); + } + + void setSelection(TextSelection t) { + if (selection == t) { + return; + } + selection = t; + markNeedsPaint(); + + if (!_shiftPressed && !_isDragging) { + // Only update extend selection origin if Shift key is not pressed and + // user is not dragging selection. + _extendSelectionOrigin = selection; + } + } + + bool get _shiftPressed => + HardwareKeyboard.instance.logicalKeysPressed + .contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed + .contains(LogicalKeyboardKey.shiftRight); + + void setStartHandleLayerLink(LayerLink value) { + if (_startHandleLayerLink == value) { + return; + } + _startHandleLayerLink = value; + markNeedsPaint(); + } + + void setEndHandleLayerLink(LayerLink value) { + if (_endHandleLayerLink == value) { + return; + } + _endHandleLayerLink = value; + markNeedsPaint(); + } + + void setScrollBottomInset(double value) { + if (scrollBottomInset == value) { + return; + } + scrollBottomInset = value; + markNeedsPaint(); + } + + double? _maxContentWidth; + + set maxContentWidth(double? value) { + if (_maxContentWidth == value) return; + _maxContentWidth = value; + markNeedsLayout(); + } + + @override + List getEndpointsForSelection( + TextSelection textSelection) { + if (textSelection.isCollapsed) { + final child = childAtPosition(textSelection.extent); + final localPosition = TextPosition( + offset: textSelection.extentOffset - child.container.offset, + affinity: textSelection.affinity, + ); + final localOffset = child.getOffsetForCaret(localPosition); + final parentData = child.parentData as BoxParentData; + return [ + TextSelectionPoint( + Offset(0, child.preferredLineHeight(localPosition)) + + localOffset + + parentData.offset, + null) + ]; + } + + final baseNode = container.queryChild(textSelection.start, false).node; + + var baseChild = firstChild; + while (baseChild != null) { + if (baseChild.container == baseNode) { + break; + } + baseChild = childAfter(baseChild); + } + assert(baseChild != null); + + final baseParentData = baseChild!.parentData as BoxParentData; + final baseSelection = + localSelection(baseChild.container, textSelection, true); + var basePoint = baseChild.getBaseEndpointForSelection(baseSelection); + basePoint = TextSelectionPoint( + basePoint.point + baseParentData.offset, + basePoint.direction, + ); + + final extentNode = container.queryChild(textSelection.end, false).node; + RenderEditableBox? extentChild = baseChild; + + /// Trap shortening the text of a link which can cause selection to extend off end of line + if (extentNode == null) { + while (true) { + final next = childAfter(extentChild); + if (next == null) { + break; + } + } + } else { + while (extentChild != null) { + if (extentChild.container == extentNode) { + break; + } + extentChild = childAfter(extentChild); + } + } + assert(extentChild != null); + + final extentParentData = extentChild!.parentData as BoxParentData; + final extentSelection = + localSelection(extentChild.container, textSelection, true); + var extentPoint = + extentChild.getExtentEndpointForSelection(extentSelection); + extentPoint = TextSelectionPoint( + extentPoint.point + extentParentData.offset, + extentPoint.direction, + ); + + return [basePoint, extentPoint]; + } + + Offset? _lastTapDownPosition; + + // Used on Desktop (mouse and keyboard enabled platforms) as base offset + // for extending selection, either with combination of `Shift` + Click or + // by dragging + TextSelection? _extendSelectionOrigin; + + @override + void handleTapDown(TapDownDetails details) { + _lastTapDownPosition = details.globalPosition; + } + + bool _isDragging = false; + + void handleDragStart(DragStartDetails details) { + _isDragging = true; + + final newSelection = selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + + if (newSelection == null) return; + // Make sure to remember the origin for extend selection. + _extendSelectionOrigin = newSelection; + } + + void handleDragEnd(DragEndDetails details) { + _isDragging = false; + onSelectionCompleted(); + } + + @override + void selectWordsInRange( + Offset from, + Offset? to, + SelectionChangedCause cause, + ) { + final firstPosition = getPositionForOffset(from); + final firstWord = selectWordAtPosition(firstPosition); + final lastWord = + to == null ? firstWord : selectWordAtPosition(getPositionForOffset(to)); + + _handleSelectionChange( + TextSelection( + baseOffset: firstWord.base.offset, + extentOffset: lastWord.extent.offset, + affinity: firstWord.affinity, + ), + cause, + ); + } + + void _handleSelectionChange( + TextSelection nextSelection, + SelectionChangedCause cause, + ) { + final focusingEmpty = nextSelection.baseOffset == 0 && + nextSelection.extentOffset == 0 && + !_hasFocus; + if (nextSelection == selection && + cause != SelectionChangedCause.keyboard && + !focusingEmpty) { + return; + } + onSelectionChanged(nextSelection, cause); + } + + /// Extends current selection to the position closest to specified offset. + void extendSelection(Offset to, {required SelectionChangedCause cause}) { + /// The below logic does not exactly match the native version because + /// we do not allow swapping of base and extent positions. + assert(_extendSelectionOrigin != null); + final position = getPositionForOffset(to); + + if (position.offset < _extendSelectionOrigin!.baseOffset) { + _handleSelectionChange( + TextSelection( + baseOffset: position.offset, + extentOffset: _extendSelectionOrigin!.extentOffset, + affinity: selection.affinity, + ), + cause, + ); + } else if (position.offset > _extendSelectionOrigin!.extentOffset) { + _handleSelectionChange( + TextSelection( + baseOffset: _extendSelectionOrigin!.baseOffset, + extentOffset: position.offset, + affinity: selection.affinity, + ), + cause, + ); + } + } + + @override + void selectWordEdge(SelectionChangedCause cause) { + assert(_lastTapDownPosition != null); + final position = getPositionForOffset(_lastTapDownPosition!); + final child = childAtPosition(position); + final nodeOffset = child.container.offset; + final localPosition = TextPosition( + offset: position.offset - nodeOffset, + affinity: position.affinity, + ); + final localWord = child.getWordBoundary(localPosition); + final word = TextRange( + start: localWord.start + nodeOffset, + end: localWord.end + nodeOffset, + ); + + // Don't change selection if the selected word is a placeholder. + if (child.container.style.attributes + .containsKey(Attribute.placeholder.key)) { + return; + } + + if (position.offset - word.start <= 1 && word.end != position.offset) { + _handleSelectionChange( + TextSelection.collapsed(offset: word.start), + cause, + ); + } else { + _handleSelectionChange( + TextSelection.collapsed( + offset: word.end, affinity: TextAffinity.upstream), + cause, + ); + } + } + + @override + TextSelection? selectPositionAt({ + required Offset from, + required SelectionChangedCause cause, + Offset? to, + }) { + final fromPosition = getPositionForOffset(from); + final toPosition = to == null ? null : getPositionForOffset(to); + + var baseOffset = fromPosition.offset; + var extentOffset = fromPosition.offset; + if (toPosition != null) { + baseOffset = math.min(fromPosition.offset, toPosition.offset); + extentOffset = math.max(fromPosition.offset, toPosition.offset); + } + + final newSelection = TextSelection( + baseOffset: baseOffset, + extentOffset: extentOffset, + affinity: fromPosition.affinity, + ); + + // Call [onSelectionChanged] only when the selection actually changed. + _handleSelectionChange(newSelection, cause); + return newSelection; + } + + @override + void selectWord(SelectionChangedCause cause) { + selectWordsInRange(_lastTapDownPosition!, null, cause); + } + + @override + void selectPosition({required SelectionChangedCause cause}) { + selectPositionAt(from: _lastTapDownPosition!, cause: cause); + } + + @override + TextSelection selectWordAtPosition(TextPosition position) { + final word = getWordBoundary(position); + // When long-pressing past the end of the text, we want a collapsed cursor. + if (position.offset >= word.end) { + return TextSelection.fromPosition(position); + } + return TextSelection(baseOffset: word.start, extentOffset: word.end); + } + + @override + TextSelection selectLineAtPosition(TextPosition position) { + final line = getLineAtOffset(position); + + // When long-pressing past the end of the text, we want a collapsed cursor. + if (position.offset >= line.end) { + return TextSelection.fromPosition(position); + } + return TextSelection(baseOffset: line.start, extentOffset: line.end); + } + + @override + void performLayout() { + assert(() { + if (!scrollable || !constraints.hasBoundedHeight) return true; + throw FlutterError.fromParts([ + ErrorSummary('RenderEditableContainerBox must have ' + 'unlimited space along its main axis when it is scrollable.'), + ErrorDescription('RenderEditableContainerBox does not clip or' + ' resize its children, so it must be ' + 'placed in a parent that does not constrain the main ' + 'axis.'), + ErrorHint( + 'You probably want to put the RenderEditableContainerBox inside a ' + 'RenderViewport with a matching main axis or disable the ' + 'scrollable property.') + ]); + }()); + assert(() { + if (constraints.hasBoundedWidth) return true; + throw FlutterError.fromParts([ + ErrorSummary('RenderEditableContainerBox must have a bounded' + ' constraint for its cross axis.'), + ErrorDescription('RenderEditableContainerBox forces its children to ' + "expand to fit the RenderEditableContainerBox's container, " + 'so it must be placed in a parent that constrains the cross ' + 'axis to a finite dimension.'), + ]); + }()); + + resolvePadding(); + assert(resolvedPadding != null); + + var mainAxisExtent = resolvedPadding!.top; + var child = firstChild; + final innerConstraints = BoxConstraints.tightFor( + width: math.min( + _maxContentWidth ?? double.infinity, constraints.maxWidth)) + .deflate(resolvedPadding!); + final leftOffset = _maxContentWidth == null + ? 0.0 + : math.max((constraints.maxWidth - _maxContentWidth!) / 2, 0); + while (child != null) { + child.layout(innerConstraints, parentUsesSize: true); + final childParentData = child.parentData as EditableContainerParentData + ..offset = Offset(resolvedPadding!.left + leftOffset, mainAxisExtent); + mainAxisExtent += child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + mainAxisExtent += resolvedPadding!.bottom; + size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); + + assert(size.isFinite); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_hasFocus && + _cursorController.show.value && + !_cursorController.style.paintAboveText) { + _paintFloatingCursor(context, offset); + } + defaultPaint(context, offset); + _updateSelectionExtentsVisibility(offset + _paintOffset); + _paintHandleLayers(context, getEndpointsForSelection(selection)); + + if (_hasFocus && + _cursorController.show.value && + _cursorController.style.paintAboveText) { + _paintFloatingCursor(context, offset); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + void _paintHandleLayers( + PaintingContext context, List endpoints) { + var startPoint = endpoints[0].point; + startPoint = Offset( + startPoint.dx.clamp(0.0, size.width), + startPoint.dy.clamp(0.0, size.height), + ); + context.pushLayer( + LeaderLayer(link: _startHandleLayerLink, offset: startPoint), + super.paint, + Offset.zero, + ); + if (endpoints.length == 2) { + var endPoint = endpoints[1].point; + endPoint = Offset( + endPoint.dx.clamp(0.0, size.width), + endPoint.dy.clamp(0.0, size.height), + ); + context.pushLayer( + LeaderLayer(link: _endHandleLayerLink, offset: endPoint), + super.paint, + Offset.zero, + ); + } + } + + @override + double preferredLineHeight(TextPosition position) { + final child = childAtPosition(position); + return child.preferredLineHeight( + TextPosition(offset: position.offset - child.container.offset)); + } + + @override + TextPosition getPositionForOffset(Offset offset) { + final local = globalToLocal(offset); + final child = childAtOffset(local); + + final parentData = child.parentData as BoxParentData; + final localOffset = local - parentData.offset; + final localPosition = child.getPositionForOffset(localOffset); + return TextPosition( + offset: localPosition.offset + child.container.offset, + affinity: localPosition.affinity, + ); + } + + /// Returns the y-offset of the editor at which [selection] is visible. + /// + /// The offset is the distance from the top of the editor and is the minimum + /// from the current scroll position until [selection] becomes visible. + /// Returns null if [selection] is already visible. + /// + /// Finds the closest scroll offset that fully reveals the editing cursor. + /// + /// The `scrollOffset` parameter represents current scroll offset in the + /// parent viewport. + /// + /// The `offsetInViewport` parameter represents the editor's vertical offset + /// in the parent viewport. This value should normally be 0.0 if this editor + /// is the only child of the viewport or if it's the topmost child. Otherwise + /// it should be a positive value equal to total height of all siblings of + /// this editor from above it. + /// + /// Returns `null` if the cursor is currently visible. + double? getOffsetToRevealCursor( + double viewportHeight, double scrollOffset, double offsetInViewport) { + // Endpoints coordinates represents lower left or lower right corner of + // the selection. If we want to scroll up to reveal the caret we need to + // adjust the dy value by the height of the line. We also add a small margin + // so that the caret is not too close to the edge of the viewport. + final endpoints = getEndpointsForSelection(selection); + + // when we drag the right handle, we should get the last point + TextSelectionPoint endpoint; + if (selection.isCollapsed) { + endpoint = endpoints.first; + } else { + if (selection is DragTextSelection) { + endpoint = (selection as DragTextSelection).first + ? endpoints.first + : endpoints.last; + } else { + endpoint = endpoints.first; + } + } + + // Collapsed selection => caret + final child = childAtPosition(selection.extent); + const kMargin = 8.0; + + final caretTop = endpoint.point.dy - + child.preferredLineHeight(TextPosition( + offset: selection.extentOffset - child.container.documentOffset)) - + kMargin + + offsetInViewport + + scrollBottomInset; + final caretBottom = + endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset; + double? dy; + if (caretTop < scrollOffset) { + dy = caretTop; + } else if (caretBottom > scrollOffset + viewportHeight) { + dy = caretBottom - viewportHeight; + } + if (dy == null) { + return null; + } + // Clamping to 0.0 so that the content does not jump unnecessarily. + return math.max(dy, 0); + } + + @override + Rect getLocalRectForCaret(TextPosition position) { + final targetChild = childAtPosition(position); + final localPosition = targetChild.globalToLocalPosition(position); + + final childLocalRect = targetChild.getLocalRectForCaret(localPosition); + + final boxParentData = targetChild.parentData as BoxParentData; + return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); + } + + // Start floating cursor + + FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter( + floatingCursorRect: _floatingCursorRect, + style: _cursorController.style, + ); + + bool _floatingCursorOn = false; + Rect? _floatingCursorRect; + + TextPosition get floatingCursorTextPosition => _floatingCursorTextPosition; + late TextPosition _floatingCursorTextPosition; + + // The relative origin in relation to the distance the user has theoretically + // dragged the floating cursor offscreen. + // This value is used to account for the difference + // in the rendering position and the raw offset value. + Offset _relativeOrigin = Offset.zero; + Offset? _previousOffset; + bool _resetOriginOnLeft = false; + bool _resetOriginOnRight = false; + bool _resetOriginOnTop = false; + bool _resetOriginOnBottom = false; + + /// Returns the position within the editor closest to the raw cursor offset. + Offset calculateBoundedFloatingCursorOffset( + Offset rawCursorOffset, double preferredLineHeight) { + var deltaPosition = Offset.zero; + final topBound = kFloatingCursorAddedMargin.top; + final bottomBound = + size.height - preferredLineHeight + kFloatingCursorAddedMargin.bottom; + final leftBound = kFloatingCursorAddedMargin.left; + final rightBound = size.width - kFloatingCursorAddedMargin.right; + + if (_previousOffset != null) { + deltaPosition = rawCursorOffset - _previousOffset!; + } + + // If the raw cursor offset has gone off an edge, + // we want to reset the relative origin of + // the dragging when the user drags back into the field. + if (_resetOriginOnLeft && deltaPosition.dx > 0) { + _relativeOrigin = + Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); + _resetOriginOnLeft = false; + } else if (_resetOriginOnRight && deltaPosition.dx < 0) { + _relativeOrigin = + Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); + _resetOriginOnRight = false; + } + if (_resetOriginOnTop && deltaPosition.dy > 0) { + _relativeOrigin = + Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); + _resetOriginOnTop = false; + } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { + _relativeOrigin = + Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); + _resetOriginOnBottom = false; + } + + final currentX = rawCursorOffset.dx - _relativeOrigin.dx; + final currentY = rawCursorOffset.dy - _relativeOrigin.dy; + final double adjustedX = + math.min(math.max(currentX, leftBound), rightBound); + final double adjustedY = + math.min(math.max(currentY, topBound), bottomBound); + final adjustedOffset = Offset(adjustedX, adjustedY); + + if (currentX < leftBound && deltaPosition.dx < 0) { + _resetOriginOnLeft = true; + } else if (currentX > rightBound && deltaPosition.dx > 0) { + _resetOriginOnRight = true; + } + if (currentY < topBound && deltaPosition.dy < 0) { + _resetOriginOnTop = true; + } else if (currentY > bottomBound && deltaPosition.dy > 0) { + _resetOriginOnBottom = true; + } + + _previousOffset = rawCursorOffset; + + return adjustedOffset; + } + + @override + void setFloatingCursor(FloatingCursorDragState dragState, + Offset boundedOffset, TextPosition textPosition, + {double? resetLerpValue}) { + if (floatingCursorDisabled) return; + + if (dragState == FloatingCursorDragState.Start) { + _relativeOrigin = Offset.zero; + _previousOffset = null; + _resetOriginOnBottom = false; + _resetOriginOnTop = false; + _resetOriginOnRight = false; + _resetOriginOnBottom = false; + } + _floatingCursorOn = dragState != FloatingCursorDragState.End; + if (_floatingCursorOn) { + _floatingCursorTextPosition = textPosition; + final sizeAdjustment = resetLerpValue != null + ? EdgeInsets.lerp( + kFloatingCaretSizeIncrease, EdgeInsets.zero, resetLerpValue)! + : kFloatingCaretSizeIncrease; + final child = childAtPosition(textPosition); + final caretPrototype = + child.getCaretPrototype(child.globalToLocalPosition(textPosition)); + _floatingCursorRect = + sizeAdjustment.inflateRect(caretPrototype).shift(boundedOffset); + _cursorController + .setFloatingCursorTextPosition(_floatingCursorTextPosition); + } else { + _floatingCursorRect = null; + _cursorController.setFloatingCursorTextPosition(null); + } + markNeedsPaint(); + } + + void _paintFloatingCursor(PaintingContext context, Offset offset) { + _floatingCursorPainter.paint(context.canvas); + } + + // End floating cursor + + // Start TextLayoutMetrics implementation + + /// Return a [TextSelection] containing the line of the given [TextPosition]. + @override + TextSelection getLineAtOffset(TextPosition position) { + final child = childAtPosition(position); + final nodeOffset = child.container.offset; + final localPosition = TextPosition( + offset: position.offset - nodeOffset, affinity: position.affinity); + final localLineRange = child.getLineBoundary(localPosition); + final line = TextRange( + start: localLineRange.start + nodeOffset, + end: localLineRange.end + nodeOffset, + ); + return TextSelection(baseOffset: line.start, extentOffset: line.end); + } + + @override + TextRange getWordBoundary(TextPosition position) { + final child = childAtPosition(position); + final nodeOffset = child.container.offset; + final localPosition = TextPosition( + offset: position.offset - nodeOffset, affinity: position.affinity); + final localWord = child.getWordBoundary(localPosition); + return TextRange( + start: localWord.start + nodeOffset, + end: localWord.end + nodeOffset, + ); + } + + /// Returns the TextPosition after moving by the vertical offset. + TextPosition getTextPositionMoveVertical( + TextPosition position, double verticalOffset) { + final caretOfs = localToGlobal(_getOffsetForCaret(position)); + return getPositionForOffset(caretOfs.translate(0, verticalOffset)); + } + + /// Returns the TextPosition above the given offset into the text. + /// + /// If the offset is already on the first line, the offset of the first + /// character will be returned. + @override + TextPosition getTextPositionAbove(TextPosition position) { + final child = childAtPosition(position); + final localPosition = + TextPosition(offset: position.offset - child.container.documentOffset); + + var newPosition = child.getPositionAbove(localPosition); + + if (newPosition == null) { + // There was no text above in the current child, check the direct + // sibling. + final sibling = childBefore(child); + if (sibling == null) { + // reached beginning of the document, move to the + // first character + newPosition = const TextPosition(offset: 0); + } else { + final caretOffset = child.getOffsetForCaret(localPosition); + final testPosition = TextPosition(offset: sibling.container.length - 1); + final testOffset = sibling.getOffsetForCaret(testPosition); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + final siblingPosition = sibling.getPositionForOffset(finalOffset); + newPosition = TextPosition( + offset: sibling.container.documentOffset + siblingPosition.offset); + } + } else { + newPosition = TextPosition( + offset: child.container.documentOffset + newPosition.offset); + } + return newPosition; + } + + /// Returns the TextPosition below the given offset into the text. + /// + /// If the offset is already on the last line, the offset of the last + /// character will be returned. + @override + TextPosition getTextPositionBelow(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.container.documentOffset, + ); + + var newPosition = child.getPositionBelow(localPosition); + + if (newPosition == null) { + // There was no text below in the current child, check the direct sibling. + final sibling = childAfter(child); + if (sibling == null) { + // reached end of the document, move to the + // last character + newPosition = TextPosition(offset: document.length - 1); + } else { + final caretOffset = child.getOffsetForCaret(localPosition); + const testPosition = TextPosition(offset: 0); + final testOffset = sibling.getOffsetForCaret(testPosition); + final finalOffset = Offset(caretOffset.dx, testOffset.dy); + final siblingPosition = sibling.getPositionForOffset(finalOffset); + newPosition = TextPosition( + offset: sibling.container.documentOffset + siblingPosition.offset, + ); + } + } else { + newPosition = TextPosition( + offset: child.container.documentOffset + newPosition.offset, + ); + } + return newPosition; + } + + // End TextLayoutMetrics implementation + QuillVerticalCaretMovementRun startVerticalCaretMovement( + TextPosition startPosition) { + return QuillVerticalCaretMovementRun( + this, + startPosition, + ); + } + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + markNeedsLayout(); + } +} diff --git a/lib/src/editor/render/utils/quill_vertical_caret_movement.dart b/lib/src/editor/render/utils/quill_vertical_caret_movement.dart new file mode 100644 index 000000000..925ef2db9 --- /dev/null +++ b/lib/src/editor/render/utils/quill_vertical_caret_movement.dart @@ -0,0 +1,36 @@ +import 'dart:ui'; +import 'package:meta/meta.dart'; +import '../render_editor.dart'; + +@internal +class QuillVerticalCaretMovementRun implements Iterator { + QuillVerticalCaretMovementRun( + this._editor, + this._currentTextPosition, + ); + + TextPosition _currentTextPosition; + + final RenderEditor _editor; + + @override + TextPosition get current { + return _currentTextPosition; + } + + @override + bool moveNext() { + _currentTextPosition = _editor.getTextPositionBelow(_currentTextPosition); + return true; + } + + bool movePrevious() { + _currentTextPosition = _editor.getTextPositionAbove(_currentTextPosition); + return true; + } + + void moveVertical(double verticalOffset) { + _currentTextPosition = _editor.getTextPositionMoveVertical( + _currentTextPosition, verticalOffset); + } +} diff --git a/lib/src/editor/render/utils/render_editor_utils.dart b/lib/src/editor/render/utils/render_editor_utils.dart new file mode 100644 index 000000000..f463e470c --- /dev/null +++ b/lib/src/editor/render/utils/render_editor_utils.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; + +/// Signature for the callback that reports when the user changes the selection +/// (including the cursor location). +/// +/// Used by [RenderEditor.onSelectionChanged]. +typedef TextSelectionChangedHandler = void Function( + TextSelection selection, SelectionChangedCause cause); + +/// Signature for the callback that reports when a selection action is actually +/// completed and ratified. Completion is defined as when the user input has +/// concluded for an entire selection action. For simple taps and keyboard input +/// events that change the selection, this callback is invoked immediately +/// following the TextSelectionChangedHandler. For long taps, the selection is +/// considered complete at the up event of a long tap. For drag selections, the +/// selection completes once the drag/pan event ends or is interrupted. +/// +/// Used by [RenderEditor.onSelectionCompleted]. +typedef TextSelectionCompletedHandler = void Function(); + +// The padding applied to text field. Used to determine the bounds when +// moving the floating cursor. +const EdgeInsets kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5); + +// The additional size on the x and y axis with which to expand the prototype +// cursor to render the floating cursor in pixels. +const EdgeInsets kFloatingCaretSizeIncrease = + EdgeInsets.symmetric(horizontal: 0.5, vertical: 1); diff --git a/lib/src/editor/widgets/cursor.dart b/lib/src/editor/widgets/cursor.dart index de126e909..d043c9835 100644 --- a/lib/src/editor/widgets/cursor.dart +++ b/lib/src/editor/widgets/cursor.dart @@ -1,96 +1,6 @@ import 'dart:async'; - import 'package:flutter/widgets.dart'; - -import '../../common/utils/platform.dart'; -import 'box.dart'; - -/// Style properties of editing cursor. -class CursorStyle { - const CursorStyle({ - required this.color, - required this.backgroundColor, - this.width = 1.0, - this.height, - this.radius, - this.offset, - this.opacityAnimates = false, - this.paintAboveText = false, - }); - - /// The color to use when painting the cursor. - final Color color; - - /// The color to use when painting the background cursor aligned with the text - /// while rendering the floating cursor. - final Color backgroundColor; - - /// How thick the cursor will be. - /// - /// The cursor will draw under the text. The cursor width will extend - /// to the right of the boundary between characters for left-to-right text - /// and to the left for right-to-left text. This corresponds to extending - /// downstream relative to the selected position. Negative values may be used - /// to reverse this behavior. - final double width; - - /// How tall the cursor will be. - /// - /// By default, the cursor height is set to the preferred line height of the - /// text. - final double? height; - - /// How rounded the corners of the cursor should be. - /// - /// By default, the cursor has no radius. - final Radius? radius; - - /// The offset that is used, in pixels, when painting the cursor on screen. - /// - /// By default, the cursor position should be set to an offset of - /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android - /// platforms. The origin from where the offset is applied to is the arbitrary - /// location where the cursor ends up being rendered from by default. - final Offset? offset; - - /// Whether the cursor will animate from fully transparent to fully opaque - /// during each cursor blink. - /// - /// By default, the cursor opacity will animate on iOS platforms and will not - /// animate on Android platforms. - final bool opacityAnimates; - - /// If the cursor should be painted on top of the text or underneath it. - /// - /// By default, the cursor should be painted on top for iOS platforms and - /// underneath for Android platforms. - final bool paintAboveText; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CursorStyle && - runtimeType == other.runtimeType && - color == other.color && - backgroundColor == other.backgroundColor && - width == other.width && - height == other.height && - radius == other.radius && - offset == other.offset && - opacityAnimates == other.opacityAnimates && - paintAboveText == other.paintAboveText; - - @override - int get hashCode => - color.hashCode ^ - backgroundColor.hashCode ^ - width.hashCode ^ - height.hashCode ^ - radius.hashCode ^ - offset.hashCode ^ - opacityAnimates.hashCode ^ - paintAboveText.hashCode; -} +import 'styles/cursor_style.dart'; /// Controls the cursor of an editable widget. /// @@ -236,112 +146,3 @@ class CursorCont extends ChangeNotifier { blink.value = show.value && _blinkOpacityController.value > 0; } } - -/// Paints the editing cursor. -class CursorPainter { - CursorPainter({ - required this.editable, - required this.style, - required this.prototype, - required this.color, - required this.devicePixelRatio, - }); - - final RenderContentProxyBox? editable; - final CursorStyle style; - final Rect prototype; - final Color color; - final double devicePixelRatio; - - /// Paints cursor on [canvas] at specified [position]. - /// [offset] is global top left (x, y) of text line - /// [position] is relative (x) in text line - void paint( - Canvas canvas, - Offset offset, - TextPosition position, - bool lineHasEmbed, - ) { - // relative (x, y) to global offset - var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype); - if (lineHasEmbed && relativeCaretOffset == Offset.zero) { - relativeCaretOffset = editable!.getOffsetForCaret( - TextPosition( - offset: position.offset - 1, affinity: position.affinity), - prototype); - // Hardcoded 6 as estimate of the width of a character - relativeCaretOffset = - Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy); - } - - final caretOffset = relativeCaretOffset + offset; - var caretRect = prototype.shift(caretOffset); - if (style.offset != null) { - caretRect = caretRect.shift(style.offset!); - } - - if (caretRect.left < 0.0) { - // For iOS the cursor may get clipped by the scroll view when - // it's located at a beginning of a line. We ensure that this - // does not happen here. This may result in the cursor being painted - // closer to the character on the right, but it's arguably better - // then painting clipped cursor (or even cursor completely hidden). - caretRect = caretRect.shift(Offset(-caretRect.left, 0)); - } - - final caretHeight = editable!.getFullHeightForCaret(position); - if (caretHeight != null) { - if (isAppleOSApp) { - // Center the caret vertically along the text. - caretRect = Rect.fromLTWH( - caretRect.left, - caretRect.top + (caretHeight - caretRect.height) / 2, - caretRect.width, - caretRect.height, - ); - } else { - // Override the height to take the full height of the glyph at the - // TextPosition when not on iOS. iOS has special handling that - // creates a taller caret. - caretRect = Rect.fromLTWH( - caretRect.left, - caretRect.top - 2.0, - caretRect.width, - caretHeight, - ); - } - } - - final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect); - if (!pixelPerfectOffset.isFinite) { - return; - } - caretRect = caretRect.shift(pixelPerfectOffset); - - final paint = Paint()..color = color; - if (style.radius == null) { - canvas.drawRect(caretRect, paint); - } else { - final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); - canvas.drawRRect(caretRRect, paint); - } - } - - Offset _getPixelPerfectCursorOffset( - Rect caretRect, - ) { - final caretPosition = editable!.localToGlobal(caretRect.topLeft); - final pixelMultiple = 1.0 / devicePixelRatio; - - final pixelPerfectOffsetX = caretPosition.dx.isFinite - ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - - caretPosition.dx - : caretPosition.dx; - final pixelPerfectOffsetY = caretPosition.dy.isFinite - ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - - caretPosition.dy - : caretPosition.dy; - - return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY); - } -} diff --git a/lib/src/editor/widgets/delegate.dart b/lib/src/editor/widgets/delegate.dart index c47a2d88e..5b33a9af5 100644 --- a/lib/src/editor/widgets/delegate.dart +++ b/lib/src/editor/widgets/delegate.dart @@ -6,9 +6,9 @@ import 'package:flutter/scheduler.dart'; import '../../common/utils/platform.dart'; import '../../document/attribute.dart'; import '../../document/nodes/leaf.dart'; -import '../editor.dart'; -import '../raw_editor/raw_editor.dart'; -import 'text/text_selection.dart'; +import '../raw_editor/editor_state.dart'; +import '../render/render_editor.dart'; +import 'text/selection/text_selection.dart'; typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); diff --git a/lib/src/editor/widgets/painters/cursor_painter.dart b/lib/src/editor/widgets/painters/cursor_painter.dart new file mode 100644 index 000000000..ec07cdafc --- /dev/null +++ b/lib/src/editor/widgets/painters/cursor_painter.dart @@ -0,0 +1,113 @@ +import 'dart:ui'; +import '../../../common/utils/platform.dart'; +import '../box.dart'; +import '../styles/cursor_style.dart'; + +/// Paints the editing cursor. +class CursorPainter { + CursorPainter({ + required this.editable, + required this.style, + required this.prototype, + required this.color, + required this.devicePixelRatio, + }); + + final RenderContentProxyBox? editable; + final CursorStyle style; + final Rect prototype; + final Color color; + final double devicePixelRatio; + + /// Paints cursor on [canvas] at specified [position]. + /// [offset] is global top left (x, y) of text line + /// [position] is relative (x) in text line + void paint( + Canvas canvas, + Offset offset, + TextPosition position, + bool lineHasEmbed, + ) { + // relative (x, y) to global offset + var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype); + if (lineHasEmbed && relativeCaretOffset == Offset.zero) { + relativeCaretOffset = editable!.getOffsetForCaret( + TextPosition( + offset: position.offset - 1, affinity: position.affinity), + prototype); + // Hardcoded 6 as estimate of the width of a character + relativeCaretOffset = + Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy); + } + + final caretOffset = relativeCaretOffset + offset; + var caretRect = prototype.shift(caretOffset); + if (style.offset != null) { + caretRect = caretRect.shift(style.offset!); + } + + if (caretRect.left < 0.0) { + // For iOS the cursor may get clipped by the scroll view when + // it's located at a beginning of a line. We ensure that this + // does not happen here. This may result in the cursor being painted + // closer to the character on the right, but it's arguably better + // then painting clipped cursor (or even cursor completely hidden). + caretRect = caretRect.shift(Offset(-caretRect.left, 0)); + } + + final caretHeight = editable!.getFullHeightForCaret(position); + if (caretHeight != null) { + if (isAppleOSApp) { + // Center the caret vertically along the text. + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top + (caretHeight - caretRect.height) / 2, + caretRect.width, + caretRect.height, + ); + } else { + // Override the height to take the full height of the glyph at the + // TextPosition when not on iOS. iOS has special handling that + // creates a taller caret. + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top - 2.0, + caretRect.width, + caretHeight, + ); + } + } + + final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect); + if (!pixelPerfectOffset.isFinite) { + return; + } + caretRect = caretRect.shift(pixelPerfectOffset); + + final paint = Paint()..color = color; + if (style.radius == null) { + canvas.drawRect(caretRect, paint); + } else { + final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); + canvas.drawRRect(caretRRect, paint); + } + } + + Offset _getPixelPerfectCursorOffset( + Rect caretRect, + ) { + final caretPosition = editable!.localToGlobal(caretRect.topLeft); + final pixelMultiple = 1.0 / devicePixelRatio; + + final pixelPerfectOffsetX = caretPosition.dx.isFinite + ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - + caretPosition.dx + : caretPosition.dx; + final pixelPerfectOffsetY = caretPosition.dy.isFinite + ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - + caretPosition.dy + : caretPosition.dy; + + return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY); + } +} diff --git a/lib/src/editor/widgets/float_cursor.dart b/lib/src/editor/widgets/painters/floating_cursor_painter.dart similarity index 95% rename from lib/src/editor/widgets/float_cursor.dart rename to lib/src/editor/widgets/painters/floating_cursor_painter.dart index f5849ae01..169519a8a 100644 --- a/lib/src/editor/widgets/float_cursor.dart +++ b/lib/src/editor/widgets/painters/floating_cursor_painter.dart @@ -1,8 +1,7 @@ -// The corner radius of the floating cursor in pixels. import 'dart:ui'; +import '../styles/cursor_style.dart'; -import 'cursor.dart'; - +// The corner radius of the floating cursor in pixels. const Radius _kFloatingCaretRadius = Radius.circular(1); /// Floating painter responsible for painting the floating cursor when diff --git a/lib/src/editor/widgets/proxies/baseline_proxy.dart b/lib/src/editor/widgets/proxies/baseline_proxy.dart new file mode 100644 index 000000000..07a7fc7ae --- /dev/null +++ b/lib/src/editor/widgets/proxies/baseline_proxy.dart @@ -0,0 +1,80 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +class BaselineProxy extends SingleChildRenderObjectWidget { + const BaselineProxy({ + super.key, + super.child, + this.textStyle, + this.padding, + }); + + final TextStyle? textStyle; + final EdgeInsets? padding; + + @override + RenderBaselineProxy createRenderObject(BuildContext context) { + return RenderBaselineProxy( + null, + textStyle!, + padding, + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderBaselineProxy renderObject) { + renderObject + ..textStyle = textStyle! + ..padding = padding!; + } +} + +class RenderBaselineProxy extends RenderProxyBox { + RenderBaselineProxy( + RenderParagraph? super.child, + TextStyle textStyle, + EdgeInsets? padding, + ) : _prototypePainter = TextPainter( + text: TextSpan(text: ' ', style: textStyle), + textDirection: TextDirection.ltr, + strutStyle: + StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)); + + final TextPainter _prototypePainter; + + set textStyle(TextStyle value) { + if (_prototypePainter.text!.style == value) { + return; + } + _prototypePainter.text = TextSpan(text: ' ', style: value); + markNeedsLayout(); + } + + EdgeInsets? _padding; + + set padding(EdgeInsets value) { + if (_padding == value) { + return; + } + _padding = value; + markNeedsLayout(); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) => + _prototypePainter.computeDistanceToActualBaseline(baseline); + // SEE What happens + _padding?.top; + + @override + void performLayout() { + super.performLayout(); + _prototypePainter.layout(); + } + + @override + void dispose() { + super.dispose(); + _prototypePainter.dispose(); + } +} diff --git a/lib/src/editor/widgets/proxies/embed_proxy.dart b/lib/src/editor/widgets/proxies/embed_proxy.dart new file mode 100644 index 000000000..9a8291adf --- /dev/null +++ b/lib/src/editor/widgets/proxies/embed_proxy.dart @@ -0,0 +1,53 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import '../box.dart'; + +class EmbedProxy extends SingleChildRenderObjectWidget { + const EmbedProxy(Widget child, {super.key}) : super(child: child); + + @override + RenderEmbedProxy createRenderObject(BuildContext context) => + RenderEmbedProxy(null); +} + +class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { + RenderEmbedProxy(super.child); + + @override + List getBoxesForSelection(TextSelection selection) { + if (!selection.isCollapsed) { + return [ + TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr) + ]; + } + + final left = selection.extentOffset == 0 ? 0.0 : size.width; + final right = selection.extentOffset == 0 ? 0.0 : size.width; + return [ + TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr) + ]; + } + + @override + double getFullHeightForCaret(TextPosition position) => size.height; + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + assert( + position.offset == 1 || position.offset == 0 || position.offset == -1); + return position.offset <= 0 + ? Offset.zero + : Offset(size.width - caretPrototype.width, 0); + } + + @override + TextPosition getPositionForOffset(Offset offset) => + TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0); + + @override + TextRange getWordBoundary(TextPosition position) => + const TextRange(start: 0, end: 1); + + @override + double get preferredLineHeight => size.height; +} diff --git a/lib/src/editor/widgets/proxies/proxy.dart b/lib/src/editor/widgets/proxies/proxy.dart new file mode 100644 index 000000000..814aa7a39 --- /dev/null +++ b/lib/src/editor/widgets/proxies/proxy.dart @@ -0,0 +1,4 @@ +export 'baseline_proxy.dart'; +export 'embed_proxy.dart'; +export 'render_paragraph_proxy.dart'; +export 'rich_text_proxy.dart'; diff --git a/lib/src/editor/widgets/proxies/render_paragraph_proxy.dart b/lib/src/editor/widgets/proxies/render_paragraph_proxy.dart new file mode 100644 index 000000000..92a273d86 --- /dev/null +++ b/lib/src/editor/widgets/proxies/render_paragraph_proxy.dart @@ -0,0 +1,134 @@ +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; + +import '../box.dart'; + +class RenderParagraphProxy extends RenderProxyBox + implements RenderContentProxyBox { + RenderParagraphProxy( + RenderParagraph? super.child, + TextStyle textStyle, + TextAlign textAlign, + TextDirection textDirection, + TextScaler textScaler, + StrutStyle strutStyle, + Locale locale, + TextWidthBasis textWidthBasis, + TextHeightBehavior? textHeightBehavior, + ) : _prototypePainter = TextPainter( + text: TextSpan(text: ' ', style: textStyle), + textAlign: textAlign, + textDirection: textDirection, + textScaler: textScaler, + strutStyle: strutStyle, + locale: locale, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + ); + + final TextPainter _prototypePainter; + + set textStyle(TextStyle value) { + if (_prototypePainter.text!.style == value) { + return; + } + _prototypePainter.text = TextSpan(text: ' ', style: value); + markNeedsLayout(); + } + + set textAlign(TextAlign value) { + if (_prototypePainter.textAlign == value) { + return; + } + _prototypePainter.textAlign = value; + markNeedsLayout(); + } + + set textDirection(TextDirection value) { + if (_prototypePainter.textDirection == value) { + return; + } + _prototypePainter.textDirection = value; + markNeedsLayout(); + } + + set textScaler(TextScaler value) { + if (_prototypePainter.textScaler == value) { + return; + } + _prototypePainter.textScaler = value; + markNeedsLayout(); + } + + set strutStyle(StrutStyle value) { + if (_prototypePainter.strutStyle == value) { + return; + } + _prototypePainter.strutStyle = value; + markNeedsLayout(); + } + + set locale(Locale value) { + if (_prototypePainter.locale == value) { + return; + } + _prototypePainter.locale = value; + markNeedsLayout(); + } + + set textWidthBasis(TextWidthBasis value) { + if (_prototypePainter.textWidthBasis == value) { + return; + } + _prototypePainter.textWidthBasis = value; + markNeedsLayout(); + } + + set textHeightBehavior(TextHeightBehavior? value) { + if (_prototypePainter.textHeightBehavior == value) { + return; + } + _prototypePainter.textHeightBehavior = value; + markNeedsLayout(); + } + + @override + RenderParagraph? get child => super.child as RenderParagraph?; + + @override + double get preferredLineHeight => _prototypePainter.preferredLineHeight; + + @override + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) => + child!.getOffsetForCaret(position, caretPrototype); + + @override + TextPosition getPositionForOffset(Offset offset) => + child!.getPositionForOffset(offset); + + @override + double? getFullHeightForCaret(TextPosition position) => + child!.getFullHeightForCaret(position); + + @override + TextRange getWordBoundary(TextPosition position) => + child!.getWordBoundary(position); + + @override + List getBoxesForSelection(TextSelection selection) => child! + .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.max); + + @override + void performLayout() { + super.performLayout(); + _prototypePainter.layout( + minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + } + + @override + void dispose() { + super.dispose(); + _prototypePainter.dispose(); + } +} diff --git a/lib/src/editor/widgets/proxies/rich_text_proxy.dart b/lib/src/editor/widgets/proxies/rich_text_proxy.dart new file mode 100644 index 000000000..e12b5ca49 --- /dev/null +++ b/lib/src/editor/widgets/proxies/rich_text_proxy.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; + +import 'render_paragraph_proxy.dart'; + +class RichTextProxy extends SingleChildRenderObjectWidget { + /// Child argument should be an instance of RichText widget. + const RichTextProxy({ + required RichText super.child, + required this.textStyle, + required this.textAlign, + required this.textDirection, + required this.locale, + required this.strutStyle, + required this.textScaler, + this.textWidthBasis = TextWidthBasis.parent, + this.textHeightBehavior, + super.key, + }); + + final TextStyle textStyle; + final TextAlign textAlign; + final TextDirection textDirection; + final TextScaler textScaler; + final Locale locale; + final StrutStyle strutStyle; + final TextWidthBasis textWidthBasis; + final TextHeightBehavior? textHeightBehavior; + + @override + RenderParagraphProxy createRenderObject(BuildContext context) { + return RenderParagraphProxy( + null, + textStyle, + textAlign, + textDirection, + textScaler, + strutStyle, + locale, + textWidthBasis, + textHeightBehavior, + ); + } + + @override + void updateRenderObject( + BuildContext context, covariant RenderParagraphProxy renderObject) { + renderObject + ..textStyle = textStyle + ..textAlign = textAlign + ..textDirection = textDirection + ..textScaler = textScaler + ..locale = locale + ..strutStyle = strutStyle + ..textWidthBasis = textWidthBasis + ..textHeightBehavior = textHeightBehavior; + } +} diff --git a/lib/src/editor/widgets/proxy.dart b/lib/src/editor/widgets/proxy.dart deleted file mode 100644 index 9504a11f6..000000000 --- a/lib/src/editor/widgets/proxy.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -import 'box.dart'; - -class BaselineProxy extends SingleChildRenderObjectWidget { - const BaselineProxy({ - super.key, - super.child, - this.textStyle, - this.padding, - }); - - final TextStyle? textStyle; - final EdgeInsets? padding; - - @override - RenderBaselineProxy createRenderObject(BuildContext context) { - return RenderBaselineProxy( - null, - textStyle!, - padding, - ); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderBaselineProxy renderObject) { - renderObject - ..textStyle = textStyle! - ..padding = padding!; - } -} - -class RenderBaselineProxy extends RenderProxyBox { - RenderBaselineProxy( - RenderParagraph? super.child, - TextStyle textStyle, - EdgeInsets? padding, - ) : _prototypePainter = TextPainter( - text: TextSpan(text: ' ', style: textStyle), - textDirection: TextDirection.ltr, - strutStyle: - StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)); - - final TextPainter _prototypePainter; - - set textStyle(TextStyle value) { - if (_prototypePainter.text!.style == value) { - return; - } - _prototypePainter.text = TextSpan(text: ' ', style: value); - markNeedsLayout(); - } - - EdgeInsets? _padding; - - set padding(EdgeInsets value) { - if (_padding == value) { - return; - } - _padding = value; - markNeedsLayout(); - } - - @override - double computeDistanceToActualBaseline(TextBaseline baseline) => - _prototypePainter.computeDistanceToActualBaseline(baseline); - // SEE What happens + _padding?.top; - - @override - void performLayout() { - super.performLayout(); - _prototypePainter.layout(); - } - - @override - void dispose() { - super.dispose(); - _prototypePainter.dispose(); - } -} - -class EmbedProxy extends SingleChildRenderObjectWidget { - const EmbedProxy(Widget child, {super.key}) : super(child: child); - - @override - RenderEmbedProxy createRenderObject(BuildContext context) => - RenderEmbedProxy(null); -} - -class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { - RenderEmbedProxy(super.child); - - @override - List getBoxesForSelection(TextSelection selection) { - if (!selection.isCollapsed) { - return [ - TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr) - ]; - } - - final left = selection.extentOffset == 0 ? 0.0 : size.width; - final right = selection.extentOffset == 0 ? 0.0 : size.width; - return [ - TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr) - ]; - } - - @override - double getFullHeightForCaret(TextPosition position) => size.height; - - @override - Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { - assert( - position.offset == 1 || position.offset == 0 || position.offset == -1); - return position.offset <= 0 - ? Offset.zero - : Offset(size.width - caretPrototype.width, 0); - } - - @override - TextPosition getPositionForOffset(Offset offset) => - TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0); - - @override - TextRange getWordBoundary(TextPosition position) => - const TextRange(start: 0, end: 1); - - @override - double get preferredLineHeight => size.height; -} - -class RichTextProxy extends SingleChildRenderObjectWidget { - /// Child argument should be an instance of RichText widget. - const RichTextProxy({ - required RichText super.child, - required this.textStyle, - required this.textAlign, - required this.textDirection, - required this.locale, - required this.strutStyle, - required this.textScaler, - this.textWidthBasis = TextWidthBasis.parent, - this.textHeightBehavior, - super.key, - }); - - final TextStyle textStyle; - final TextAlign textAlign; - final TextDirection textDirection; - final TextScaler textScaler; - final Locale locale; - final StrutStyle strutStyle; - final TextWidthBasis textWidthBasis; - final TextHeightBehavior? textHeightBehavior; - - @override - RenderParagraphProxy createRenderObject(BuildContext context) { - return RenderParagraphProxy(null, textStyle, textAlign, textDirection, - textScaler, strutStyle, locale, textWidthBasis, textHeightBehavior); - } - - @override - void updateRenderObject( - BuildContext context, covariant RenderParagraphProxy renderObject) { - renderObject - ..textStyle = textStyle - ..textAlign = textAlign - ..textDirection = textDirection - ..textScaler = textScaler - ..locale = locale - ..strutStyle = strutStyle - ..textWidthBasis = textWidthBasis - ..textHeightBehavior = textHeightBehavior; - } -} - -class RenderParagraphProxy extends RenderProxyBox - implements RenderContentProxyBox { - RenderParagraphProxy( - RenderParagraph? super.child, - TextStyle textStyle, - TextAlign textAlign, - TextDirection textDirection, - TextScaler textScaler, - StrutStyle strutStyle, - Locale locale, - TextWidthBasis textWidthBasis, - TextHeightBehavior? textHeightBehavior, - ) : _prototypePainter = TextPainter( - text: TextSpan(text: ' ', style: textStyle), - textAlign: textAlign, - textDirection: textDirection, - textScaler: textScaler, - strutStyle: strutStyle, - locale: locale, - textWidthBasis: textWidthBasis, - textHeightBehavior: textHeightBehavior, - ); - - final TextPainter _prototypePainter; - - set textStyle(TextStyle value) { - if (_prototypePainter.text!.style == value) { - return; - } - _prototypePainter.text = TextSpan(text: ' ', style: value); - markNeedsLayout(); - } - - set textAlign(TextAlign value) { - if (_prototypePainter.textAlign == value) { - return; - } - _prototypePainter.textAlign = value; - markNeedsLayout(); - } - - set textDirection(TextDirection value) { - if (_prototypePainter.textDirection == value) { - return; - } - _prototypePainter.textDirection = value; - markNeedsLayout(); - } - - set textScaler(TextScaler value) { - if (_prototypePainter.textScaler == value) { - return; - } - _prototypePainter.textScaler = value; - markNeedsLayout(); - } - - set strutStyle(StrutStyle value) { - if (_prototypePainter.strutStyle == value) { - return; - } - _prototypePainter.strutStyle = value; - markNeedsLayout(); - } - - set locale(Locale value) { - if (_prototypePainter.locale == value) { - return; - } - _prototypePainter.locale = value; - markNeedsLayout(); - } - - set textWidthBasis(TextWidthBasis value) { - if (_prototypePainter.textWidthBasis == value) { - return; - } - _prototypePainter.textWidthBasis = value; - markNeedsLayout(); - } - - set textHeightBehavior(TextHeightBehavior? value) { - if (_prototypePainter.textHeightBehavior == value) { - return; - } - _prototypePainter.textHeightBehavior = value; - markNeedsLayout(); - } - - @override - RenderParagraph? get child => super.child as RenderParagraph?; - - @override - double get preferredLineHeight => _prototypePainter.preferredLineHeight; - - @override - Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) => - child!.getOffsetForCaret(position, caretPrototype); - - @override - TextPosition getPositionForOffset(Offset offset) => - child!.getPositionForOffset(offset); - - @override - double? getFullHeightForCaret(TextPosition position) => - child!.getFullHeightForCaret(position); - - @override - TextRange getWordBoundary(TextPosition position) => - child!.getWordBoundary(position); - - @override - List getBoxesForSelection(TextSelection selection) => child! - .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.max); - - @override - void performLayout() { - super.performLayout(); - _prototypePainter.layout( - minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); - } - - @override - void dispose() { - super.dispose(); - _prototypePainter.dispose(); - } -} diff --git a/lib/src/editor/widgets/styles/block_styles.dart b/lib/src/editor/widgets/styles/block_styles.dart new file mode 100644 index 000000000..4af52e258 --- /dev/null +++ b/lib/src/editor/widgets/styles/block_styles.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import '../../../common/structs/horizontal_spacing.dart'; +import '../../../common/structs/vertical_spacing.dart'; +import '../../style_widgets/style_widgets.dart'; +import '../text/utils/text_block_utils.dart'; + +/// Style theme applied to a block of rich text, including single-line +/// paragraphs. +@immutable +class DefaultTextBlockStyle { + const DefaultTextBlockStyle( + this.style, + this.horizontalSpacing, + this.verticalSpacing, + this.lineSpacing, + this.decoration, + ); + + /// Base text style for a text block. + final TextStyle style; + + /// Horizontal spacing around a text block. + final HorizontalSpacing horizontalSpacing; + + /// Vertical spacing around a text block. + final VerticalSpacing verticalSpacing; + + /// Vertical spacing for individual lines within a text block. + /// + final VerticalSpacing lineSpacing; + + /// Decoration of a text block. + /// + /// Decoration, if present, is painted in the content area, excluding + /// any [spacing]. + final BoxDecoration? decoration; +} + +@immutable +class DefaultListBlockStyle extends DefaultTextBlockStyle { + const DefaultListBlockStyle( + super.style, + super.horizontalSpacing, + super.verticalSpacing, + super.lineSpacing, + super.decoration, + this.checkboxUIBuilder, { + this.indentWidthBuilder = TextBlockUtils.defaultIndentWidthBuilder, + this.numberPointWidthBuilder = + TextBlockUtils.defaultNumberPointWidthBuilder, + }); + + final QuillCheckboxBuilder? checkboxUIBuilder; + final LeadingBlockIndentWidth indentWidthBuilder; + final LeadingBlockNumberPointWidth numberPointWidthBuilder; +} diff --git a/lib/src/editor/widgets/styles/cursor_style.dart b/lib/src/editor/widgets/styles/cursor_style.dart new file mode 100644 index 000000000..7f3015935 --- /dev/null +++ b/lib/src/editor/widgets/styles/cursor_style.dart @@ -0,0 +1,88 @@ +import 'dart:ui'; + +/// Style properties of editing cursor. +class CursorStyle { + const CursorStyle({ + required this.color, + required this.backgroundColor, + this.width = 1.0, + this.height, + this.radius, + this.offset, + this.opacityAnimates = false, + this.paintAboveText = false, + }); + + /// The color to use when painting the cursor. + final Color color; + + /// The color to use when painting the background cursor aligned with the text + /// while rendering the floating cursor. + final Color backgroundColor; + + /// How thick the cursor will be. + /// + /// The cursor will draw under the text. The cursor width will extend + /// to the right of the boundary between characters for left-to-right text + /// and to the left for right-to-left text. This corresponds to extending + /// downstream relative to the selected position. Negative values may be used + /// to reverse this behavior. + final double width; + + /// How tall the cursor will be. + /// + /// By default, the cursor height is set to the preferred line height of the + /// text. + final double? height; + + /// How rounded the corners of the cursor should be. + /// + /// By default, the cursor has no radius. + final Radius? radius; + + /// The offset that is used, in pixels, when painting the cursor on screen. + /// + /// By default, the cursor position should be set to an offset of + /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android + /// platforms. The origin from where the offset is applied to is the arbitrary + /// location where the cursor ends up being rendered from by default. + final Offset? offset; + + /// Whether the cursor will animate from fully transparent to fully opaque + /// during each cursor blink. + /// + /// By default, the cursor opacity will animate on iOS platforms and will not + /// animate on Android platforms. + final bool opacityAnimates; + + /// If the cursor should be painted on top of the text or underneath it. + /// + /// By default, the cursor should be painted on top for iOS platforms and + /// underneath for Android platforms. + final bool paintAboveText; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CursorStyle && + runtimeType == other.runtimeType && + color == other.color && + backgroundColor == other.backgroundColor && + width == other.width && + height == other.height && + radius == other.radius && + offset == other.offset && + opacityAnimates == other.opacityAnimates && + paintAboveText == other.paintAboveText; + + @override + int get hashCode => + color.hashCode ^ + backgroundColor.hashCode ^ + width.hashCode ^ + height.hashCode ^ + radius.hashCode ^ + offset.hashCode ^ + opacityAnimates.hashCode ^ + paintAboveText.hashCode; +} diff --git a/lib/src/editor/widgets/default_styles.dart b/lib/src/editor/widgets/styles/default_styles.dart similarity index 70% rename from lib/src/editor/widgets/default_styles.dart rename to lib/src/editor/widgets/styles/default_styles.dart index a0f1b084e..e20ce1045 100644 --- a/lib/src/editor/widgets/default_styles.dart +++ b/lib/src/editor/widgets/styles/default_styles.dart @@ -1,176 +1,14 @@ import 'package:flutter/material.dart'; -import '../../common/structs/horizontal_spacing.dart'; -import '../../common/structs/vertical_spacing.dart'; -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'; - -class QuillStyles extends InheritedWidget { - const QuillStyles({ - required this.data, - required super.child, - super.key, - }); - - final DefaultStyles data; - - @override - bool updateShouldNotify(QuillStyles oldWidget) { - return data != oldWidget.data; - } - - static DefaultStyles? getStyles(BuildContext context, bool nullOk) { - final widget = context.dependOnInheritedWidgetOfExactType(); - if (widget == null && nullOk) { - return null; - } - assert(widget != null); - return widget!.data; - } -} - -/// Style theme applied to a block of rich text, including single-line -/// paragraphs. -@immutable -class DefaultTextBlockStyle { - const DefaultTextBlockStyle( - this.style, - this.horizontalSpacing, - this.verticalSpacing, - this.lineSpacing, - this.decoration, - ); - - /// Base text style for a text block. - final TextStyle style; - - /// Horizontal spacing around a text block. - final HorizontalSpacing horizontalSpacing; - - /// Vertical spacing around a text block. - final VerticalSpacing verticalSpacing; - - /// Vertical spacing for individual lines within a text block. - /// - final VerticalSpacing lineSpacing; - - /// Decoration of a text block. - /// - /// Decoration, if present, is painted in the content area, excluding - /// any [spacing]. - final BoxDecoration? decoration; -} - -/// Theme data for inline code. -class InlineCodeStyle { - InlineCodeStyle({ - required this.style, - this.header1, - this.header2, - this.header3, - this.header4, - this.header5, - this.header6, - this.backgroundColor, - this.radius, - }); - - /// Base text style for an inline code. - final TextStyle style; - - /// Style override for inline code in header level 1. - final TextStyle? header1; - - /// Style override for inline code in headings level 2. - final TextStyle? header2; - - /// Style override for inline code in headings level 3. - final TextStyle? header3; - - /// Style override for inline code in headings level 4. - final TextStyle? header4; - - /// Style override for inline code in headings level 5. - final TextStyle? header5; - - /// Style override for inline code in headings level 6. - final TextStyle? header6; - - /// Background color for inline code. - final Color? backgroundColor; - - /// Radius used when paining the background. - final Radius? radius; - - /// Returns effective style to use for inline code for the specified - /// [lineStyle]. - TextStyle styleFor(Style lineStyle) { - if (lineStyle.containsKey(Attribute.h1.key)) { - return header1 ?? style; - } - if (lineStyle.containsKey(Attribute.h2.key)) { - return header2 ?? style; - } - if (lineStyle.containsKey(Attribute.h3.key)) { - return header3 ?? style; - } - if (lineStyle.containsKey(Attribute.h4.key)) { - return header4 ?? style; - } - if (lineStyle.containsKey(Attribute.h5.key)) { - return header5 ?? style; - } - if (lineStyle.containsKey(Attribute.h6.key)) { - return header6 ?? style; - } - return style; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other is! InlineCodeStyle) { - return false; - } - return other.style == style && - other.header1 == header1 && - other.header2 == header2 && - other.header3 == header3 && - other.header4 == header4 && - other.header5 == header5 && - other.header6 == header6 && - other.backgroundColor == backgroundColor && - other.radius == radius; - } - - @override - int get hashCode => Object.hash(style, header1, header2, header3, header4, - header5, header6, backgroundColor, radius); -} - -@immutable -class DefaultListBlockStyle extends DefaultTextBlockStyle { - const DefaultListBlockStyle( - super.style, - super.horizontalSpacing, - super.verticalSpacing, - super.lineSpacing, - super.decoration, - this.checkboxUIBuilder, { - this.indentWidthBuilder = TextBlockUtils.defaultIndentWidthBuilder, - this.numberPointWidthBuilder = - TextBlockUtils.defaultNumberPointWidthBuilder, - }); - - final QuillCheckboxBuilder? checkboxUIBuilder; - final LeadingBlockIndentWidth indentWidthBuilder; - final LeadingBlockNumberPointWidth numberPointWidthBuilder; -} +import '../../../common/structs/horizontal_spacing.dart'; +import '../../../common/structs/vertical_spacing.dart'; +import '../../../common/utils/platform.dart'; +import 'block_styles.dart'; +import 'inline_code_style.dart'; + +export 'block_styles.dart'; +export 'inline_code_style.dart'; +export 'quill_styles.dart'; @immutable class DefaultStyles { diff --git a/lib/src/editor/widgets/styles/inline_code_style.dart b/lib/src/editor/widgets/styles/inline_code_style.dart new file mode 100644 index 000000000..992f42528 --- /dev/null +++ b/lib/src/editor/widgets/styles/inline_code_style.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import '../../../document/attribute.dart'; +import '../../../document/style.dart'; + +/// Theme data for inline code. +class InlineCodeStyle { + InlineCodeStyle({ + required this.style, + this.header1, + this.header2, + this.header3, + this.header4, + this.header5, + this.header6, + this.backgroundColor, + this.radius, + }); + + /// Base text style for an inline code. + final TextStyle style; + + /// Style override for inline code in header level 1. + final TextStyle? header1; + + /// Style override for inline code in headings level 2. + final TextStyle? header2; + + /// Style override for inline code in headings level 3. + final TextStyle? header3; + + /// Style override for inline code in headings level 4. + final TextStyle? header4; + + /// Style override for inline code in headings level 5. + final TextStyle? header5; + + /// Style override for inline code in headings level 6. + final TextStyle? header6; + + /// Background color for inline code. + final Color? backgroundColor; + + /// Radius used when paining the background. + final Radius? radius; + + /// Returns effective style to use for inline code for the specified + /// [lineStyle]. + TextStyle styleFor(Style lineStyle) { + if (lineStyle.containsKey(Attribute.h1.key)) { + return header1 ?? style; + } + if (lineStyle.containsKey(Attribute.h2.key)) { + return header2 ?? style; + } + if (lineStyle.containsKey(Attribute.h3.key)) { + return header3 ?? style; + } + if (lineStyle.containsKey(Attribute.h4.key)) { + return header4 ?? style; + } + if (lineStyle.containsKey(Attribute.h5.key)) { + return header5 ?? style; + } + if (lineStyle.containsKey(Attribute.h6.key)) { + return header6 ?? style; + } + return style; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! InlineCodeStyle) { + return false; + } + return other.style == style && + other.header1 == header1 && + other.header2 == header2 && + other.header3 == header3 && + other.header4 == header4 && + other.header5 == header5 && + other.header6 == header6 && + other.backgroundColor == backgroundColor && + other.radius == radius; + } + + @override + int get hashCode => Object.hash(style, header1, header2, header3, header4, + header5, header6, backgroundColor, radius); +} diff --git a/lib/src/editor/widgets/styles/quill_styles.dart b/lib/src/editor/widgets/styles/quill_styles.dart new file mode 100644 index 000000000..687990156 --- /dev/null +++ b/lib/src/editor/widgets/styles/quill_styles.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'default_styles.dart'; + +class QuillStyles extends InheritedWidget { + const QuillStyles({ + required this.data, + required super.child, + super.key, + }); + + final DefaultStyles data; + + @override + bool updateShouldNotify(QuillStyles oldWidget) { + return data != oldWidget.data; + } + + static DefaultStyles? getStyles(BuildContext context, bool nullOk) { + final widget = context.dependOnInheritedWidgetOfExactType(); + if (widget == null && nullOk) { + return null; + } + assert(widget != null); + return widget!.data; + } +} diff --git a/lib/src/editor/widgets/text/block/editable_block.dart b/lib/src/editor/widgets/text/block/editable_block.dart new file mode 100644 index 000000000..8854d98f8 --- /dev/null +++ b/lib/src/editor/widgets/text/block/editable_block.dart @@ -0,0 +1,62 @@ +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_block.dart'; + +@internal +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/render_editable_block.dart b/lib/src/editor/widgets/text/block/render_editable_block.dart new file mode 100644 index 000000000..15f78c8bb --- /dev/null +++ b/lib/src/editor/widgets/text/block/render_editable_block.dart @@ -0,0 +1,315 @@ +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; + +import '../../../../document/nodes/block.dart'; +import '../../../render/render_editable_container_box.dart'; +import '../../box.dart'; +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/text_block.dart similarity index 50% rename from lib/src/editor/widgets/text/text_block.dart rename to lib/src/editor/widgets/text/block/text_block.dart index 36c7f6ca8..48060ea34 100644 --- a/lib/src/editor/widgets/text/text_block.dart +++ b/lib/src/editor/widgets/text/block/text_block.dart @@ -1,27 +1,25 @@ 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 '../../../editor_toolbar_shared/color.dart'; -import '../../editor.dart'; -import '../../embed/embed_editor_builder.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'; +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 '../../../../editor_toolbar_shared/color.dart'; +import '../../../embed/embed_editor_builder.dart'; +import '../../../raw_editor/builders/leading_block_builder.dart'; +import '../../cursor.dart'; +import '../../default_leading_components/leading_components.dart'; +import '../../delegate.dart'; +import '../../link.dart'; +import '../../styles/default_styles.dart'; +import '../line/editable_text_line.dart'; +import '../line/text_line.dart'; +import '../utils/text_block_utils.dart'; +import 'editable_block.dart'; const List arabianRomanNumbers = [ 1000, @@ -120,7 +118,7 @@ class EditableTextBlock extends StatelessWidget { assert(debugCheckHasMediaQuery(context)); final defaultStyles = QuillStyles.getStyles(context, false); - return _EditableBlock( + return EditableBlock( block: block, textDirection: textDirection, horizontalSpacing: horizontalSpacing, @@ -418,362 +416,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/line/editable_text_line.dart b/lib/src/editor/widgets/text/line/editable_text_line.dart new file mode 100644 index 000000000..e58d5102d --- /dev/null +++ b/lib/src/editor/widgets/text/line/editable_text_line.dart @@ -0,0 +1,86 @@ +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 '../../styles/default_styles.dart'; +import 'render_editable_line.dart'; + +@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_line.dart b/lib/src/editor/widgets/text/line/render_editable_line.dart new file mode 100644 index 000000000..e70d2e171 --- /dev/null +++ b/lib/src/editor/widgets/text/line/render_editable_line.dart @@ -0,0 +1,835 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; + +import '../../../../common/utils/platform.dart'; +import '../../../../document/attribute.dart'; +import '../../../../document/nodes/container.dart' as container_node; +import '../../../../document/nodes/leaf.dart' as leaf; +import '../../../../document/nodes/line.dart'; +import '../../box.dart'; +import '../../cursor.dart'; +import '../../painters/cursor_painter.dart'; +import '../../styles/default_styles.dart'; +import '../selection/text_selection.dart'; +import 'editable_text_line.dart'; + +@internal +enum TextLineSlot { leading, body } + +/// Creates new editable paragraph render box. +@internal +class RenderEditableTextLine extends RenderEditableBox { + 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); + 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; + } + } +} 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..87b4baedc --- /dev/null +++ b/lib/src/editor/widgets/text/line/text_line.dart @@ -0,0 +1,718 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../../flutter_quill.dart'; +import '../../../../common/utils/color.dart'; +import '../../../../common/utils/font.dart'; +import '../../../../common/utils/platform.dart'; +import '../../../../document/nodes/leaf.dart' as leaf; +import '../../delegate.dart'; +import '../../keyboard_listener.dart'; +import '../../proxies/embed_proxy.dart'; +import '../../proxies/rich_text_proxy.dart'; + +class TextLine extends StatefulWidget { + const TextLine({ + required this.line, + required this.embedBuilder, + required this.textSpanBuilder, + 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 TextSpanBuilder textSpanBuilder; + 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, + EmbedContext( + controller: widget.controller, + node: embed, + readOnly: widget.readOnly, + inline: false, + textStyle: 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, + widget.textSpanBuilder, + ); + } + + // 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, + widget.textSpanBuilder, + )); + 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, + EmbedContext( + controller: widget.controller, + node: child, + readOnly: widget.readOnly, + inline: true, + textStyle: 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, + widget.textSpanBuilder, + )); + } + + 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; + } + + InlineSpan _buildTextSpan( + DefaultStyles defaultStyles, + LinkedList nodes, + TextStyle lineStyle, + TextSpanBuilder textSpanBuilder, + ) { + if (nodes.isEmpty && kIsWeb) { + nodes = LinkedList()..add(leaf.QuillText()); + } + + 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, + textSpanBuilder, + )) + .toList(growable: false); + return TextSpan(children: children, style: lineStyle); + } + + final children = nodes.expand((node) { + final child = _getTextSpanFromNode( + defaultStyles, + node, + widget.line.style, + textSpanBuilder, + ); + final isNodeInComposingRange = + node.documentOffset <= widget.composingRange.start && + widget.composingRange.end <= node.documentOffset + node.length; + if (isNodeInComposingRange) { + return _splitAndApplyComposingStyle(node, child, textSpanBuilder); + } 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, + TextSpanBuilder textSpanBuilder, + ) { + 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); + + final isLink = node.style.attributes[Attribute.link.key]?.value != null; + final recognizer = _getRecognizer(node, isLink); + + return [ + textSpanBuilder( + context, + node, + 0, + textBefore, + child.style, + recognizer, + ), + textSpanBuilder( + context, + node, + composingStart, + textComposing, + composingStyle, + recognizer, + ), + textSpanBuilder( + context, + node, + composingEnd, + textAfter, + child.style, + recognizer, + ), + ]; + } + + 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, + TextSpanBuilder textSpanBuilder, + ) { + 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.config.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); + } + } + } + + final recognizer = _getRecognizer(node, isLink); + return textSpanBuilder( + context, + textNode, + 0, + textNode.value, + style, + recognizer, + ); + } + + 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 launchUrl(Uri.parse(url)); + } + + void _tapNodeLink(Node node) { + final link = node.style.attributes[Attribute.link.key]!.value; + + _tapLink(link); + } + + void _tapLink(String? link) { + if (link == null) { + return; + } + + var launchUrl = widget.onLaunchUrl; + launchUrl ??= _launchUrl; + + link = link.trim(); + if (!(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/text_selection.dart b/lib/src/editor/widgets/text/selection/text_selection.dart similarity index 99% rename from lib/src/editor/widgets/text/text_selection.dart rename to lib/src/editor/widgets/text/selection/text_selection.dart index a748e60f5..604aaaaf3 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/selection/text_selection.dart @@ -7,8 +7,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import '../../../document/nodes/node.dart'; -import '../../editor.dart'; +import '../../../../document/nodes/node.dart'; +import '../../../render/render_editor.dart'; TextSelection localSelection(Node node, TextSelection selection, fromParent) { final base = fromParent ? node.offset : node.documentOffset; 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 0758f08b5..000000000 --- a/lib/src/editor/widgets/text/text_line.dart +++ /dev/null @@ -1,1615 +0,0 @@ -import 'dart:collection'; -import 'dart:math' as math; - -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../../../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.textSpanBuilder, - 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 TextSpanBuilder textSpanBuilder; - 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, - EmbedContext( - controller: widget.controller, - node: embed, - readOnly: widget.readOnly, - inline: false, - textStyle: 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, - widget.textSpanBuilder, - ); - } - - // 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, - widget.textSpanBuilder, - )); - 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, - EmbedContext( - controller: widget.controller, - node: child, - readOnly: widget.readOnly, - inline: true, - textStyle: 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, - widget.textSpanBuilder, - )); - } - - 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; - } - - InlineSpan _buildTextSpan( - DefaultStyles defaultStyles, - LinkedList nodes, - TextStyle lineStyle, - TextSpanBuilder textSpanBuilder, - ) { - if (nodes.isEmpty && kIsWeb) { - nodes = LinkedList()..add(leaf.QuillText()); - } - - 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, - textSpanBuilder, - )) - .toList(growable: false); - return TextSpan(children: children, style: lineStyle); - } - - final children = nodes.expand((node) { - final child = _getTextSpanFromNode( - defaultStyles, - node, - widget.line.style, - textSpanBuilder, - ); - final isNodeInComposingRange = - node.documentOffset <= widget.composingRange.start && - widget.composingRange.end <= node.documentOffset + node.length; - if (isNodeInComposingRange) { - return _splitAndApplyComposingStyle(node, child, textSpanBuilder); - } 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, - TextSpanBuilder textSpanBuilder, - ) { - 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); - - final isLink = node.style.attributes[Attribute.link.key]?.value != null; - final recognizer = _getRecognizer(node, isLink); - - return [ - textSpanBuilder( - context, - node, - 0, - textBefore, - child.style, - recognizer, - ), - textSpanBuilder( - context, - node, - composingStart, - textComposing, - composingStyle, - recognizer, - ), - textSpanBuilder( - context, - node, - composingEnd, - textAfter, - child.style, - recognizer, - ), - ]; - } - - 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, - TextSpanBuilder textSpanBuilder, - ) { - 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.config.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); - } - } - } - - final recognizer = _getRecognizer(node, isLink); - return textSpanBuilder( - context, - textNode, - 0, - textNode.value, - style, - recognizer, - ); - } - - 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 launchUrl(Uri.parse(url)); - } - - void _tapNodeLink(Node node) { - final link = node.style.attributes[Attribute.link.key]!.value; - - _tapLink(link); - } - - void _tapLink(String? link) { - if (link == null) { - return; - } - - var launchUrl = widget.onLaunchUrl; - launchUrl ??= _launchUrl; - - link = link.trim(); - if (!(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); - 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; - } - } -} diff --git a/lib/src/editor/widgets/text/utils/text_block_utils.dart b/lib/src/editor/widgets/text/utils/text_block_utils.dart index a9ede7a10..ef6d1eae9 100644 --- a/lib/src/editor/widgets/text/utils/text_block_utils.dart +++ b/lib/src/editor/widgets/text/utils/text_block_utils.dart @@ -5,7 +5,7 @@ import '../../../../common/structs/horizontal_spacing.dart'; import '../../../../document/attribute.dart'; import '../../../../document/nodes/block.dart'; import '../../../../document/nodes/node.dart'; -import '../../default_styles.dart'; +import '../../styles/default_styles.dart'; typedef LeadingBlockIndentWidth = HorizontalSpacing Function( Block block, From 8e45c5a433eea40f52821bd6d4f02db3f779852a Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Thu, 20 Feb 2025 17:07:08 -0400 Subject: [PATCH 2/4] Chore: updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 072da66b5..71cf9123c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Changes + +* Reorganize editor members [#2485](https://github.com/singerdmx/flutter-quill/pull/2485) + ### Fixed * Removed unicode from `QuillText` element that causes weird caret behavior on empty lines [#2453](https://github.com/singerdmx/flutter-quill/pull/2453). From eeb359d4a36830fd4bc797fcf842997a05bbbd79 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Sat, 22 Feb 2025 22:07:46 -0400 Subject: [PATCH 3/4] Chore: restore abstract_render_editor to render_abstract_editor --- .../{abstract_render_editor.dart => render_abstract_editor.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/src/editor/render/{abstract_render_editor.dart => render_abstract_editor.dart} (100%) diff --git a/lib/src/editor/render/abstract_render_editor.dart b/lib/src/editor/render/render_abstract_editor.dart similarity index 100% rename from lib/src/editor/render/abstract_render_editor.dart rename to lib/src/editor/render/render_abstract_editor.dart From 1eefcb4fa1bc0210a7925f61a8bdd839feb65232 Mon Sep 17 00:00:00 2001 From: CatHood0 Date: Sat, 22 Feb 2025 22:11:07 -0400 Subject: [PATCH 4/4] Fix: revert deletion of quill_single_child_scroll_view file --- lib/flutter_quill.dart | 1 + .../quill_single_child_scroll_view.dart | 362 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 lib/src/editor/raw_editor/quill_single_child_scroll_view.dart diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 57a36d615..ba8c5a528 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -20,6 +20,7 @@ export 'src/editor/embed/embed_editor_builder.dart'; export 'src/editor/raw_editor/builders/leading_block_builder.dart'; export 'src/editor/raw_editor/config/events/events.dart'; export 'src/editor/raw_editor/config/raw_editor_config.dart'; +export 'src/editor/raw_editor/quill_single_child_scroll_view.dart'; export 'src/editor/raw_editor/raw_editor.dart'; export 'src/editor/raw_editor/raw_editor_state.dart'; export 'src/editor/style_widgets/style_widgets.dart'; diff --git a/lib/src/editor/raw_editor/quill_single_child_scroll_view.dart b/lib/src/editor/raw_editor/quill_single_child_scroll_view.dart new file mode 100644 index 000000000..69877c392 --- /dev/null +++ b/lib/src/editor/raw_editor/quill_single_child_scroll_view.dart @@ -0,0 +1,362 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Very similar to [SingleChildView] but with a [ViewportBuilder] argument +/// instead of a [Widget] +/// +/// Useful when child needs [ViewportOffset] ([RenderEditor] no longer uses this class!) +/// see: [SingleChildScrollView] +@Deprecated( + 'This class is no longer being used and will be removed in future versions. If you disagree, please open an issue.') +class QuillSingleChildScrollView extends StatelessWidget { + /// Creates a box in which a single widget can be scrolled. + const QuillSingleChildScrollView({ + required this.controller, + required this.viewportBuilder, + super.key, + this.physics, + this.restorationId, + }); + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + final ScrollController controller; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + final ViewportBuilder viewportBuilder; + + AxisDirection _getDirection(BuildContext context) { + return getAxisDirectionFromAxisReverseAndDirectionality( + context, + Axis.vertical, + false, + ); + } + + @override + Widget build(BuildContext context) { + final axisDirection = _getDirection(context); + final scrollController = controller; + final scrollable = Scrollable( + axisDirection: axisDirection, + controller: scrollController, + physics: physics, + restorationId: restorationId, + viewportBuilder: (context, offset) { + return _SingleChildViewport( + offset: offset, + child: viewportBuilder(context, offset), + ); + }, + ); + return scrollable; + } +} + +class _SingleChildViewport extends SingleChildRenderObjectWidget { + const _SingleChildViewport({ + required this.offset, + super.child, + }); + + final ViewportOffset offset; + + @override + _RenderSingleChildViewport createRenderObject(BuildContext context) { + return _RenderSingleChildViewport( + offset: offset, + ); + } + + @override + void updateRenderObject( + BuildContext context, _RenderSingleChildViewport renderObject) { + // Order dependency: The offset setter reads the axis direction. + renderObject.offset = offset; + } +} + +class _RenderSingleChildViewport extends RenderBox + with RenderObjectWithChildMixin + implements RenderAbstractViewport { + _RenderSingleChildViewport({ + required ViewportOffset offset, + double cacheExtent = RenderAbstractViewport.defaultCacheExtent, + RenderBox? child, + }) : _offset = offset, + _cacheExtent = cacheExtent { + this.child = child; + } + + ViewportOffset get offset => _offset; + ViewportOffset _offset; + + set offset(ViewportOffset value) { + if (value == _offset) return; + if (attached) _offset.removeListener(_hasScrolled); + _offset = value; + if (attached) _offset.addListener(_hasScrolled); + markNeedsLayout(); + } + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + double get cacheExtent => _cacheExtent; + double _cacheExtent; + + set cacheExtent(double value) { + if (value == _cacheExtent) return; + _cacheExtent = value; + markNeedsLayout(); + } + + void _hasScrolled() { + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + @override + void setupParentData(RenderObject child) { + // We don't actually use the offset argument in BoxParentData, so let's + // avoid allocating it at all. + if (child.parentData is! ParentData) child.parentData = ParentData(); + } + + @override + bool get isRepaintBoundary => true; + + double get _viewportExtent { + assert(hasSize); + return size.height; + } + + double get _minScrollExtent { + assert(hasSize); + return 0; + } + + double get _maxScrollExtent { + assert(hasSize); + if (child == null) return 0; + return math.max(0, child!.size.height - size.height); + } + + BoxConstraints _getInnerConstraints(BoxConstraints constraints) { + return constraints.widthConstraints(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) return child!.getMinIntrinsicWidth(height); + return 0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) return child!.getMaxIntrinsicWidth(height); + return 0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) return child!.getMinIntrinsicHeight(width); + return 0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) return child!.getMaxIntrinsicHeight(width); + return 0; + } + + // We don't override computeDistanceToActualBaseline(), because we + // want the default behavior (returning null). Otherwise, as you + // scroll, it would shift in its parent if the parent was baseline-aligned, + // which makes no sense. + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child == null) { + return constraints.smallest; + } + final childSize = child!.getDryLayout(_getInnerConstraints(constraints)); + return constraints.constrain(childSize); + } + + @override + void performLayout() { + final constraints = this.constraints; + if (child == null) { + size = constraints.smallest; + } else { + child!.layout(_getInnerConstraints(constraints), parentUsesSize: true); + size = constraints.constrain(child!.size); + } + + offset + ..applyViewportDimension(_viewportExtent) + ..applyContentDimensions(_minScrollExtent, _maxScrollExtent); + } + + Offset get _paintOffset => _paintOffsetForPosition(offset.pixels); + + Offset _paintOffsetForPosition(double position) { + return Offset(0, -position); + } + + bool _shouldClipAtPaintOffset(Offset paintOffset) { + assert(child != null); + return paintOffset.dx < 0 || + paintOffset.dy < 0 || + paintOffset.dx + child!.size.width > size.width || + paintOffset.dy + child!.size.height > size.height; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child != null) { + final paintOffset = _paintOffset; + + void paintContents(PaintingContext context, Offset offset) { + context.paintChild(child!, offset + paintOffset); + } + + if (_shouldClipAtPaintOffset(paintOffset)) { + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + paintContents, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + paintContents(context, offset); + } + } + } + + final _clipRectLayer = LayerHandle(); + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + final paintOffset = _paintOffset; + transform.translate(paintOffset.dx, paintOffset.dy); + } + + @override + Rect? describeApproximatePaintClip(RenderObject? child) { + if (child != null && _shouldClipAtPaintOffset(_paintOffset)) { + return Offset.zero & size; + } + return null; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child != null) { + return result.addWithPaintOffset( + offset: _paintOffset, + position: position, + hitTest: (result, transformed) { + assert(transformed == position + -_paintOffset); + return child!.hitTest(result, position: transformed); + }, + ); + } + return false; + } + + @override + RevealedOffset getOffsetToReveal( + RenderObject target, + double alignment, { + Rect? rect, + Axis? axis, // Unused, only Axis.vertical supported by this viewport. + }) { + rect ??= target.paintBounds; + if (target is! RenderBox) { + return RevealedOffset(offset: offset.pixels, rect: rect); + } + + final targetBox = target; + final transform = targetBox.getTransformTo(child); + final bounds = MatrixUtils.transformRect(transform, rect); + + final double leadingScrollOffset; + final double targetMainAxisExtent; + final double mainAxisExtent; + + mainAxisExtent = size.height; + leadingScrollOffset = bounds.top; + targetMainAxisExtent = bounds.height; + + final targetOffset = leadingScrollOffset - + (mainAxisExtent - targetMainAxisExtent) * alignment; + final targetRect = bounds.shift(_paintOffsetForPosition(targetOffset)); + return RevealedOffset(offset: targetOffset, rect: targetRect); + } + + @override + void showOnScreen({ + RenderObject? descendant, + Rect? rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (!offset.allowImplicitScrolling) { + return super.showOnScreen( + descendant: descendant, + rect: rect, + duration: duration, + curve: curve, + ); + } + + final newRect = RenderViewportBase.showInViewport( + descendant: descendant, + viewport: this, + offset: offset, + rect: rect, + duration: duration, + curve: curve, + ); + super.showOnScreen( + rect: newRect, + duration: duration, + curve: curve, + ); + } + + @override + Rect describeSemanticsClip(RenderObject child) { + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - cacheExtent, + semanticBounds.right, + semanticBounds.bottom + cacheExtent, + ); + } +}