diff --git a/packages/fleather/example/integration_test/editing_performance_test.dart b/packages/fleather/example/integration_test/editing_performance_test.dart index 80dfba6d..f795ab09 100644 --- a/packages/fleather/example/integration_test/editing_performance_test.dart +++ b/packages/fleather/example/integration_test/editing_performance_test.dart @@ -22,12 +22,12 @@ void main() { await tester.tap(find.byType(RawEditor)); controller.updateSelection(const TextSelection.collapsed(offset: 0)); await tester.pump(); - await tester.ime.typeText(iputText, finder: find.byType(RawEditor)); + await tester.ime.typeText(iputText); await tester.pump(); controller.updateSelection( TextSelection.collapsed(offset: document.length - 1)); await tester.pump(); - await tester.ime.typeText(iputText, finder: find.byType(RawEditor)); + await tester.ime.typeText(iputText); }, reportKey: 'timeline', ); diff --git a/packages/fleather/lib/src/rendering/editable_text_block.dart b/packages/fleather/lib/src/rendering/editable_text_block.dart index 94a7cfbb..20d37fc1 100644 --- a/packages/fleather/lib/src/rendering/editable_text_block.dart +++ b/packages/fleather/lib/src/rendering/editable_text_block.dart @@ -334,7 +334,26 @@ class RenderEditableTextBlock extends RenderEditableContainerBox @override List getBoxesForSelection(TextSelection selection) { - throw UnimplementedError(); + final boxes = []; + var child = firstChild; + while (child != null) { + if (intersectsWithSelection(child.node, selection, fromParent: true)) { + final childSelection = + localSelection(child.node, selection, fromParent: true); + final childOffset = (child.parentData as BoxParentData).offset; + boxes.addAll(child.getBoxesForSelection(childSelection).map((box) { + return TextBox.fromLTRBD( + box.left + childOffset.dx, + box.top + childOffset.dy, + box.right + childOffset.dx, + box.bottom + childOffset.dy, + box.direction, + ); + })); + } + child = childAfter(child); + } + return boxes; } @override diff --git a/packages/fleather/lib/src/rendering/editor.dart b/packages/fleather/lib/src/rendering/editor.dart index 0be71fab..ab264d97 100644 --- a/packages/fleather/lib/src/rendering/editor.dart +++ b/packages/fleather/lib/src/rendering/editor.dart @@ -670,6 +670,9 @@ class RenderEditor extends RenderEditableContainerBox return defaultHitTestChildren(result, position: effectivePosition); } + @override + bool hitTestSelf(Offset position) => true; + void _paintHandleLayers( PaintingContext context, List endpoints) { var startPoint = endpoints[0].point + paintOffset; @@ -757,8 +760,32 @@ class RenderEditor extends RenderEditableContainerBox final childLocalRect = targetChild.getLocalRectForCaret(localPosition); final boxParentData = targetChild.parentData as BoxParentData; - return childLocalRect.shift(Offset( - resolvedPadding!.left, boxParentData.offset.dy + paintOffset.dy)); + return childLocalRect.shift(boxParentData.offset + paintOffset); + } + + /// Returns text boxes for a document selection in this editor's coordinates. + List getBoxesForSelection(TextSelection selection) { + final boxes = []; + var child = firstChild; + while (child != null) { + if (intersectsWithSelection(child.node, selection, fromParent: true)) { + final childSelection = + localSelection(child.node, selection, fromParent: true); + final childOffset = + (child.parentData as BoxParentData).offset + paintOffset; + boxes.addAll(child.getBoxesForSelection(childSelection).map((box) { + return TextBox.fromLTRBD( + box.left + childOffset.dx, + box.top + childOffset.dy, + box.right + childOffset.dx, + box.bottom + childOffset.dy, + box.direction, + ); + })); + } + child = childAfter(child); + } + return boxes; } // Start floating cursor diff --git a/packages/fleather/lib/src/widgets/editable_text_block.dart b/packages/fleather/lib/src/widgets/editable_text_block.dart index 80b0928d..3ffb7493 100644 --- a/packages/fleather/lib/src/widgets/editable_text_block.dart +++ b/packages/fleather/lib/src/widgets/editable_text_block.dart @@ -28,6 +28,7 @@ class EditableTextBlock extends StatelessWidget { final LinkActionPicker linkActionPicker; final ValueChanged? onLaunchUrl; final EdgeInsets? contentPadding; + final FleatherScribblePlaceholder? scribblePlaceholder; const EditableTextBlock({ super.key, @@ -45,6 +46,7 @@ class EditableTextBlock extends StatelessWidget { required this.linkActionPicker, this.onLaunchUrl, this.contentPadding, + this.scribblePlaceholder, }); @override @@ -87,6 +89,7 @@ class EditableTextBlock extends StatelessWidget { linkActionPicker: linkActionPicker, onLaunchUrl: onLaunchUrl, textWidthBasis: textWidthBasis, + scribblePlaceholder: scribblePlaceholder, ), cursorController: cursorController, selection: selection, diff --git a/packages/fleather/lib/src/widgets/editor.dart b/packages/fleather/lib/src/widgets/editor.dart index 3b0dacca..2a49a98c 100644 --- a/packages/fleather/lib/src/widgets/editor.dart +++ b/packages/fleather/lib/src/widgets/editor.dart @@ -183,6 +183,11 @@ class FleatherEditor extends StatefulWidget { /// the text field from the clipboard. final bool enableInteractiveSelection; + /// Whether this editor supports platform stylus handwriting. + /// + /// On iPadOS this enables Apple Pencil Scribble. + final bool stylusHandwritingEnabled; + /// Defines how to measure the width of the rendered text when [readOnly] is /// `true`. Otherwise the value is ignored and forced to /// [TextWidthBasis.parent] @@ -317,6 +322,8 @@ class FleatherEditor extends StatefulWidget { this.autocorrect = true, this.enableSuggestions = true, this.enableInteractiveSelection = true, + this.stylusHandwritingEnabled = + EditableText.defaultStylusHandwritingEnabled, this.textWidthBasis = TextWidthBasis.parent, this.minHeight, this.maxHeight, @@ -341,12 +348,16 @@ class FleatherEditor extends StatefulWidget { class _FleatherEditorState extends State implements EditorTextSelectionGestureDetectorBuilderDelegate { GlobalKey? _editorKey; + FocusNode? _internalFocusNode; bool _showSelectionHandles = false; @override GlobalKey get editableTextKey => widget.editorKey ?? _editorKey!; + FocusNode get effectiveFocusNode => + widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + @override bool get forcePressEnabled => true; @@ -366,6 +377,10 @@ class _FleatherEditorState extends State } else if (widget.editorKey != null) { _editorKey = null; } + if (oldWidget.focusNode == null && widget.focusNode != null) { + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } } @override @@ -378,6 +393,12 @@ class _FleatherEditorState extends State _FleatherEditorSelectionGestureDetectorBuilder(state: this); } + @override + void dispose() { + _internalFocusNode?.dispose(); + super.dispose(); + } + void _handleSelectionChanged( TextSelection selection, SelectionChangedCause? cause) { final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); @@ -485,7 +506,7 @@ class _FleatherEditorState extends State Widget child = RawEditor( key: editableTextKey, controller: widget.controller, - focusNode: widget.focusNode, + focusNode: effectiveFocusNode, scrollController: widget.scrollController, scrollable: widget.scrollable, padding: widget.padding, @@ -495,6 +516,7 @@ class _FleatherEditorState extends State readOnly: widget.readOnly, enableSuggestions: widget.enableSuggestions, enableInteractiveSelection: widget.enableInteractiveSelection, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, textWidthBasis: widget.textWidthBasis, minHeight: widget.minHeight, maxHeight: widget.maxHeight, @@ -531,6 +553,7 @@ class _FleatherEditorState extends State child: FleatherActions( child: FleatherHistory( controller: widget.controller, + focusNode: effectiveFocusNode, child: child, ), ), @@ -599,6 +622,8 @@ class RawEditor extends StatefulWidget { this.autocorrect = true, this.enableSuggestions = true, this.enableInteractiveSelection = true, + this.stylusHandwritingEnabled = + EditableText.defaultStylusHandwritingEnabled, this.textWidthBasis = TextWidthBasis.parent, this.minHeight, this.maxHeight, @@ -664,6 +689,9 @@ class RawEditor extends StatefulWidget { /// Defaults to true. final bool enableSuggestions; + /// Whether this editor supports platform stylus handwriting. + final bool stylusHandwritingEnabled; + /// Callback which is triggered when the user wants to open a URL from /// a link in the document. final ValueChanged? onLaunchUrl; @@ -823,6 +851,9 @@ class RawEditor extends StatefulWidget { properties.add(DoubleProperty('minLines', minHeight, defaultValue: null)); properties.add( DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add(DiagnosticsProperty( + 'stylusHandwritingEnabled', stylusHandwritingEnabled, + defaultValue: EditableText.defaultStylusHandwritingEnabled)); properties.add(DiagnosticsProperty( 'scrollPhysics', scrollPhysics, defaultValue: null)); @@ -925,9 +956,17 @@ class RawEditorState extends EditorState TickerProviderStateMixin, RawEditorStateTextInputClientMixin, RawEditorStateSelectionDelegateMixin - implements TextSelectionDelegate { + implements TextSelectionDelegate, ScribbleClient { + static int _nextScribbleElementIdentifier = 1; + final GlobalKey _editorKey = GlobalKey(); final GlobalKey _scrollableKey = GlobalKey(); + late final String _scribbleElementIdentifier = + 'fleather-${_nextScribbleElementIdentifier++}'; + bool _scribbleElementRegistered = false; + bool _stylusHandwritingGeometryScheduled = false; + int? _scribblePlaceholderOffset; + Size? _scribblePlaceholderSize; // Theme late FleatherThemeData _themeData; @@ -974,6 +1013,20 @@ class RawEditorState extends EditorState bool get _hasFocus => effectiveFocusNode.hasFocus; + bool get _stylusHandwritingEnabled => + widget.stylusHandwritingEnabled && + !widget.readOnly && + defaultTargetPlatform == TargetPlatform.iOS; + + FleatherScribblePlaceholder? get _scribblePlaceholder { + final offset = _scribblePlaceholderOffset; + final size = _scribblePlaceholderSize; + if (offset == null || size == null) { + return null; + } + return FleatherScribblePlaceholder(documentOffset: offset, size: size); + } + @override bool get wantKeepAlive => _hasFocus; @@ -991,8 +1044,10 @@ class RawEditorState extends EditorState /// /// This property is typically used to notify the renderer of input gestures. @override - RenderEditor get renderEditor => - _editorKey.currentContext!.findRenderObject() as RenderEditor; + RenderEditor get renderEditor => _renderEditorOrNull!; + + RenderEditor? get _renderEditorOrNull => + _editorKey.currentContext?.findRenderObject() as RenderEditor?; /// Express interest in interacting with the keyboard. /// @@ -1377,6 +1432,7 @@ class RawEditorState extends EditorState void _updateSelectionOverlayForScroll() { _selectionOverlay?.updateForScroll(); + _scheduleStylusHandwritingGeometryUpdate(); } // State lifecycle: @@ -1408,6 +1464,7 @@ class RawEditorState extends EditorState // Focus effectiveFocusNode.addListener(_handleFocusChanged); + _updateScribbleRegistration(); } @override @@ -1425,6 +1482,7 @@ class RawEditorState extends EditorState } performSpellCheck(widget.controller.plainTextEditingValue.text); updateConnectionConfig(); + _scheduleStylusHandwritingGeometryUpdate(); } @override @@ -1480,10 +1538,13 @@ class RawEditorState extends EditorState updateConnectionConfig(); } } + _updateScribbleRegistration(); + _scheduleStylusHandwritingGeometryUpdate(); } @override void dispose() { + _unregisterScribbleElement(); closeConnectionIfNeeded(); assert(!hasConnection); _selectionOverlay?.dispose(); @@ -1522,6 +1583,7 @@ class RawEditorState extends EditorState _updateOrDisposeSelectionOverlayIfNeeded(); }); _verticalSelectionUpdateAction.stopCurrentVerticalRunIfSelectionChanges(); + _scheduleStylusHandwritingGeometryUpdate(); } void _handleSelectionChanged( @@ -1546,6 +1608,7 @@ class RawEditorState extends EditorState // This will show the keyboard for all selection changes on the // editor, not just changes triggered by user gestures. requestKeyboard(); + _scheduleStylusHandwritingGeometryUpdate(); if (cause == SelectionChangedCause.drag) { // When user updates the selection while dragging make sure to @@ -1607,6 +1670,156 @@ class RawEditorState extends EditorState // Inform the widget that the value of focus has changed. (so that cursor can repaint appropriately) }); updateKeepAlive(); + _scheduleStylusHandwritingGeometryUpdate(); + } + + void _updateScribbleRegistration() { + if (_stylusHandwritingEnabled && !_scribbleElementRegistered) { + TextInput.registerScribbleElement(elementIdentifier, this); + _scribbleElementRegistered = true; + } else if (!_stylusHandwritingEnabled && _scribbleElementRegistered) { + TextInput.unregisterScribbleElement(elementIdentifier); + _scribbleElementRegistered = false; + _scribblePlaceholderOffset = null; + _scribblePlaceholderSize = null; + } + } + + void _unregisterScribbleElement() { + if (_scribbleElementRegistered) { + TextInput.unregisterScribbleElement(elementIdentifier); + _scribbleElementRegistered = false; + } + } + + @override + String get elementIdentifier => _scribbleElementIdentifier; + + @override + Rect get bounds { + final editor = mounted ? _renderEditorOrNull : null; + if (editor == null || !editor.attached || !editor.hasSize) { + return Rect.zero; + } + return MatrixUtils.transformRect( + editor.getTransformTo(null), + Offset.zero & editor.size, + ); + } + + @override + bool isInScribbleRect(Rect rect) { + if (!_stylusHandwritingEnabled) { + return false; + } + final editorBounds = bounds; + if (editorBounds == Rect.zero || !editorBounds.contains(rect.center)) { + return false; + } + final editor = _renderEditorOrNull; + if (editor == null) { + return false; + } + final result = HitTestResult(); + WidgetsBinding.instance + .hitTestInView(result, rect.center, View.of(context).viewId); + return result.path.any((entry) => entry.target == editor); + } + + @override + void onScribbleFocus(Offset _) { + if (!_stylusHandwritingEnabled) { + return; + } + effectiveFocusNode.requestFocus(); + openConnectionIfNeeded(); + _scheduleStylusHandwritingGeometryUpdate(); + } + + void _scheduleStylusHandwritingGeometryUpdate() { + if (!_stylusHandwritingEnabled || !hasConnection) { + return; + } + if (_stylusHandwritingGeometryScheduled) { + return; + } + _stylusHandwritingGeometryScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _stylusHandwritingGeometryScheduled = false; + final editor = mounted ? _renderEditorOrNull : null; + if (editor == null || + !_stylusHandwritingEnabled || + !hasConnection || + !editor.attached || + !editor.hasSize) { + return; + } + final selectionRects = _buildStylusHandwritingSelectionRects(editor); + final selection = controller.selection; + final caretOffset = selection.isValid + ? selection.start.clamp(0, controller.document.length - 1) + : 0; + final caretRect = + editor.getLocalRectForCaret(TextPosition(offset: caretOffset)); + final composingRect = _buildComposingRect(editor, caretRect); + updateStylusHandwritingGeometry( + selectionRects: selectionRects, + caretRect: caretRect, + composingRect: composingRect, + ); + }); + } + + List _buildStylusHandwritingSelectionRects( + RenderEditor editor) { + final result = []; + final visibleBounds = Offset.zero & editor.size; + final text = controller.document.toPlainText(); + var graphemeStart = 0; + for (final grapheme in text.characters) { + final graphemeEnd = graphemeStart + grapheme.length; + final boxes = editor.getBoxesForSelection( + TextSelection( + baseOffset: graphemeStart, + extentOffset: graphemeEnd, + ), + ); + if (boxes.isNotEmpty) { + final box = boxes.first; + final boxRect = box.toRect(); + if (boxRect.top >= visibleBounds.bottom) { + break; + } + if (visibleBounds.overlaps(boxRect)) { + result.add(SelectionRect( + position: graphemeStart, + bounds: boxRect, + direction: box.direction, + )); + } + } + graphemeStart = graphemeEnd; + } + return result; + } + + Rect _buildComposingRect(RenderEditor editor, Rect fallback) { + final composing = currentTextEditingValue?.composing; + if (composing == null || !composing.isValid || composing.isCollapsed) { + return fallback; + } + final boxes = editor.getBoxesForSelection( + TextSelection( + baseOffset: composing.start, + extentOffset: composing.end, + ), + ); + if (boxes.isEmpty) { + return fallback; + } + return boxes + .map((box) => box.toRect()) + .reduce((value, element) => value.expandToInclude(element)); } void _updateOrDisposeSelectionOverlayIfNeeded() { @@ -1719,6 +1932,7 @@ class RawEditorState extends EditorState } } _lastBottomViewInset = bottomViewInset; + _scheduleStylusHandwritingGeometryUpdate(); } // On MacOS some actions are sent as selectors. We need to manually find the right Action and invoke it. @@ -1847,6 +2061,7 @@ class RawEditorState extends EditorState linkActionPicker: _linkActionPicker, onLaunchUrl: widget.onLaunchUrl, textWidthBasis: widget.textWidthBasis, + scribblePlaceholder: _scribblePlaceholder, ), hasFocus: _hasFocus, devicePixelRatio: MediaQuery.of(context).devicePixelRatio, @@ -1873,6 +2088,7 @@ class RawEditorState extends EditorState embedBuilder: widget.embedBuilder, linkActionPicker: _linkActionPicker, onLaunchUrl: widget.onLaunchUrl, + scribblePlaceholder: _scribblePlaceholder, ), )); } else { @@ -2100,12 +2316,36 @@ class RawEditorState extends EditorState @override void insertTextPlaceholder(Size size) { - // TODO: implement insertTextPlaceholder + if (!_stylusHandwritingEnabled || !controller.selection.isValid) { + return; + } + final maxOffset = math.max(0, controller.document.length - 1); + final offset = controller.selection.end.clamp(0, maxOffset).toInt(); + final width = renderEditor.hasSize + ? math.max( + 0.0, + math.min(renderEditor.size.width, + renderEditor.maxContentWidth ?? double.infinity) - + renderEditor.padding.horizontal, + ) + : size.width; + setState(() { + _scribblePlaceholderOffset = offset; + _scribblePlaceholderSize = Size(width, 0); + }); + _scheduleStylusHandwritingGeometryUpdate(); } @override void removeTextPlaceholder() { - // TODO: implement removeTextPlaceholder + if (_scribblePlaceholderOffset == null) { + return; + } + setState(() { + _scribblePlaceholderOffset = null; + _scribblePlaceholderSize = null; + }); + _scheduleStylusHandwritingGeometryUpdate(); } /// Returns the anchor points for the default context menu. diff --git a/packages/fleather/lib/src/widgets/editor_input_client_mixin.dart b/packages/fleather/lib/src/widgets/editor_input_client_mixin.dart index 4a10f86f..d762aa63 100644 --- a/packages/fleather/lib/src/widgets/editor_input_client_mixin.dart +++ b/packages/fleather/lib/src/widgets/editor_input_client_mixin.dart @@ -366,6 +366,28 @@ mixin RawEditorStateTextInputClientMixin on EditorState } } + /// Updates the geometry used by iPadOS Apple Pencil Scribble. + void updateStylusHandwritingGeometry({ + required List selectionRects, + required Rect caretRect, + required Rect composingRect, + }) { + if (!hasConnection) { + return; + } + final size = Size( + min(renderEditor.size.width, + renderEditor.maxContentWidth ?? double.infinity) - + renderEditor.padding.horizontal, + renderEditor.size.height); + final transform = renderEditor.getTransformTo(null); + _textInputConnection + ?..setEditableSizeAndTransform(size, transform) + ..setSelectionRects(selectionRects) + ..setCaretRect(caretRect) + ..setComposingRect(composingRect); + } + @override void insertContent(KeyboardInsertedContent content) { // TODO: implement insertContent diff --git a/packages/fleather/lib/src/widgets/field.dart b/packages/fleather/lib/src/widgets/field.dart index de8080eb..406322a3 100644 --- a/packages/fleather/lib/src/widgets/field.dart +++ b/packages/fleather/lib/src/widgets/field.dart @@ -91,6 +91,11 @@ class FleatherField extends StatefulWidget { /// the text field from the clipboard. final bool enableInteractiveSelection; + /// Whether this field supports platform stylus handwriting. + /// + /// On iPadOS this enables Apple Pencil Scribble. + final bool stylusHandwritingEnabled; + /// Defines how to measure the width of the rendered text when [readOnly] is /// `true`. Otherwise the value is ignored and forced to /// [TextWidthBasis.parent] @@ -199,6 +204,8 @@ class FleatherField extends StatefulWidget { this.autocorrect = true, this.enableSuggestions = true, this.enableInteractiveSelection = true, + this.stylusHandwritingEnabled = + EditableText.defaultStylusHandwritingEnabled, this.textWidthBasis = TextWidthBasis.parent, this.minHeight, this.maxHeight, @@ -272,6 +279,7 @@ class _FleatherFieldState extends State { autocorrect: widget.autocorrect, enableSuggestions: widget.enableSuggestions, enableInteractiveSelection: widget.enableInteractiveSelection, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, textWidthBasis: widget.textWidthBasis, minHeight: widget.minHeight, maxHeight: widget.maxHeight, diff --git a/packages/fleather/lib/src/widgets/history.dart b/packages/fleather/lib/src/widgets/history.dart index ad52253a..42f86cdc 100644 --- a/packages/fleather/lib/src/widgets/history.dart +++ b/packages/fleather/lib/src/widgets/history.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:parchment/parchment.dart'; @@ -15,12 +19,19 @@ import '../util.dart'; /// Listens to keyboard undo/redo shortcuts and send update to [controller]. class FleatherHistory extends StatefulWidget { /// Creates an instance of [FleatherHistory]. - const FleatherHistory( - {super.key, required this.child, required this.controller}); + const FleatherHistory({ + super.key, + required this.child, + required this.controller, + required this.focusNode, + }); /// The child widget of [FleatherHistory]. final Widget child; + /// Focus node used to register this editor with the iOS system undo manager. + final FocusNode focusNode; + /// The [FleatherController] to save the state of over time. final FleatherController controller; @@ -28,27 +39,103 @@ class FleatherHistory extends StatefulWidget { State createState() => _FleatherHistoryState(); } -class _FleatherHistoryState extends State { +class _FleatherHistoryState extends State + with UndoManagerClient { + Timer? _historyStateTimer; + void _undo(UndoTextIntent intent) { - widget.controller.undo(); + undo(); } void _redo(RedoTextIntent intent) { + redo(); + } + + @override + bool get canUndo => widget.controller.canUndo; + + @override + bool get canRedo => widget.controller.canRedo; + + @override + void undo() { + widget.controller.undo(); + _updateUndoState(); + } + + @override + void redo() { widget.controller.redo(); + _updateUndoState(); + } + + @override + void handlePlatformUndo(UndoDirection direction) { + switch (direction) { + case UndoDirection.undo: + undo(); + case UndoDirection.redo: + redo(); + } + } + + void _handleControllerChanged() { + _updateUndoState(); + _historyStateTimer?.cancel(); + _historyStateTimer = + Timer(const Duration(milliseconds: 500), _updateUndoState); + } + + void _handleFocusChanged() { + if (defaultTargetPlatform != TargetPlatform.iOS || + !widget.focusNode.hasFocus) { + if (UndoManager.client == this) { + UndoManager.client = null; + } + return; + } + UndoManager.client = this; + _updateUndoState(); + } + + void _updateUndoState() { + if (defaultTargetPlatform == TargetPlatform.iOS && + UndoManager.client == this) { + UndoManager.setUndoState(canUndo: canUndo, canRedo: canRedo); + } } @override void initState() { super.initState(); + widget.controller.addListener(_handleControllerChanged); + widget.focusNode.addListener(_handleFocusChanged); + _handleFocusChanged(); } @override void didUpdateWidget(FleatherHistory oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_handleControllerChanged); + widget.controller.addListener(_handleControllerChanged); + _historyStateTimer?.cancel(); + } + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + widget.focusNode.addListener(_handleFocusChanged); + } + _handleFocusChanged(); } @override void dispose() { + if (UndoManager.client == this) { + UndoManager.client = null; + } + widget.controller.removeListener(_handleControllerChanged); + widget.focusNode.removeListener(_handleFocusChanged); + _historyStateTimer?.cancel(); super.dispose(); } diff --git a/packages/fleather/lib/src/widgets/text_line.dart b/packages/fleather/lib/src/widgets/text_line.dart index 7cf3e2e2..5da5ecaa 100644 --- a/packages/fleather/lib/src/widgets/text_line.dart +++ b/packages/fleather/lib/src/widgets/text_line.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -14,6 +16,16 @@ import 'link.dart'; import 'rich_text_proxy.dart'; import 'theme.dart'; +class FleatherScribblePlaceholder { + const FleatherScribblePlaceholder({ + required this.documentOffset, + required this.size, + }); + + final int documentOffset; + final Size size; +} + /// Line of text in Fleather editor. /// /// This widget allows to render non-editable line of rich text, but can be @@ -27,6 +39,7 @@ class TextLine extends StatefulWidget { final ValueChanged? onLaunchUrl; final LinkActionPicker linkActionPicker; final TextWidthBasis textWidthBasis; + final FleatherScribblePlaceholder? scribblePlaceholder; const TextLine({ super.key, @@ -37,6 +50,7 @@ class TextLine extends StatefulWidget { required this.onLaunchUrl, required this.linkActionPicker, required this.textWidthBasis, + this.scribblePlaceholder, }); @override @@ -163,27 +177,76 @@ class _TextLineState extends State { TextSpan buildText( BuildContext context, LineNode node, FleatherThemeData theme) { - final children = node.children - .map((node) => _segmentToTextSpan(node, theme)) - .toList(growable: false); + final children = []; + final placeholderOffset = widget.scribblePlaceholder == null + ? null + : widget.scribblePlaceholder!.documentOffset - node.documentOffset; + var segmentOffset = 0; + var placeholderInserted = false; + for (final segment in node.children) { + if (!placeholderInserted && + placeholderOffset == segmentOffset && + segment is! TextNode) { + children.addAll(_buildScribblePlaceholderSpans()); + placeholderInserted = true; + } + if (!placeholderInserted && + placeholderOffset != null && + segment is TextNode && + placeholderOffset >= segmentOffset && + placeholderOffset <= segmentOffset + segment.value.length) { + final localOffset = placeholderOffset - segmentOffset; + if (localOffset > 0) { + children.add(_textSegmentToTextSpan( + segment, theme, segment.value.substring(0, localOffset))); + } + children.addAll(_buildScribblePlaceholderSpans()); + if (localOffset < segment.value.length) { + children.add(_textSegmentToTextSpan( + segment, theme, segment.value.substring(localOffset))); + } + placeholderInserted = true; + } else { + children.add(_segmentToTextSpan(segment, theme)); + } + segmentOffset += segment.length; + } + if (!placeholderInserted && + placeholderOffset != null && + placeholderOffset == segmentOffset) { + children.addAll(_buildScribblePlaceholderSpans()); + } return TextSpan( style: _getParagraphTextStyle(node.style, theme), children: children, ); } + List _buildScribblePlaceholderSpans() { + final width = widget.scribblePlaceholder!.size.width; + return [ + const _FleatherScribblePlaceholderSpan(size: Size.zero), + _FleatherScribblePlaceholderSpan(size: Size(width, 0)), + ]; + } + InlineSpan _segmentToTextSpan(Node segment, FleatherThemeData theme) { if (segment is EmbedNode) { return WidgetSpan( child: EmbedProxy(child: widget.embedBuilder(context, segment))); } final text = segment as TextNode; + return _textSegmentToTextSpan(text, theme, text.value); + } + + InlineSpan _textSegmentToTextSpan( + TextNode text, FleatherThemeData theme, String value) { final attrs = text.style; final isLink = attrs.contains(ParchmentAttribute.link); return TextSpan( - text: text.value, + text: value, style: _getInlineTextStyle(attrs, widget.node.style, theme), - recognizer: isLink && canLaunchLinks ? _getRecognizer(segment) : null, + recognizer: isLink && canLaunchLinks ? _getRecognizer(text) : null, mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null, ); } @@ -335,3 +398,27 @@ class _TextLineState extends State { return a.merge(b).apply(decoration: TextDecoration.combine(decorations)); } } + +class _FleatherScribblePlaceholderSpan extends WidgetSpan { + const _FleatherScribblePlaceholderSpan({required this.size}) + : super(child: const SizedBox.shrink()); + + final Size size; + + @override + void build( + ui.ParagraphBuilder builder, { + TextScaler textScaler = TextScaler.noScaling, + List? dimensions, + }) { + assert(debugAssertIsValid()); + final hasStyle = style != null; + if (hasStyle) { + builder.pushStyle(style!.getTextStyle(textScaler: textScaler)); + } + builder.addPlaceholder(size.width, size.height, alignment); + if (hasStyle) { + builder.pop(); + } + } +} diff --git a/packages/fleather/test/widgets/editor_scribble_test.dart b/packages/fleather/test/widgets/editor_scribble_test.dart new file mode 100644 index 00000000..65732e1f --- /dev/null +++ b/packages/fleather/test/widgets/editor_scribble_test.dart @@ -0,0 +1,230 @@ +import 'package:fleather/fleather.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('registers and unregisters the editor as a Scribble client', + (tester) async { + final fixture = await _pumpEditor(tester); + + expect(TextInput.scribbleClients[fixture.state.elementIdentifier], + same(fixture.state)); + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pump(); + + expect( + TextInput.scribbleClients.containsKey(fixture.state.elementIdentifier), + isFalse); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('does not register Scribble when disabled or read only', + (tester) async { + final disabled = await _pumpEditor(tester, stylusHandwritingEnabled: false); + expect( + TextInput.scribbleClients.containsKey(disabled.state.elementIdentifier), + isFalse); + + final readOnly = await _pumpEditor(tester, readOnly: true); + expect( + TextInput.scribbleClients.containsKey(readOnly.state.elementIdentifier), + isFalse); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets( + 'Scribble focus keeps the selection and opens the input connection', + (tester) async { + final fixture = await _pumpEditor(tester); + final editorRect = tester.getRect(find.byType(RawEditor)); + final textRect = tester.getRect(find.byType(TextLine).first); + final blankPosition = Offset(editorRect.center.dx, editorRect.bottom - 20); + fixture.controller + .updateSelection(const TextSelection.collapsed(offset: 3)); + await tester.pump(); + + expect(textRect.contains(blankPosition), isFalse); + expect(fixture.state.bounds.contains(blankPosition), isTrue); + expect( + fixture.state.isInScribbleRect( + Rect.fromCenter(center: blankPosition, width: 2, height: 2)), + isTrue); + final outsideRect = Rect.fromCenter( + center: Offset(editorRect.center.dx, editorRect.bottom + 4), + width: 2, + height: 20); + expect(fixture.state.bounds.overlaps(outsideRect), isTrue); + expect(fixture.state.isInScribbleRect(outsideRect), isFalse); + + fixture.state.onScribbleFocus(blankPosition); + await tester.pump(); + + expect(fixture.focusNode.hasFocus, isTrue); + expect( + fixture.controller.selection, const TextSelection.collapsed(offset: 3)); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('routes iOS system undo and redo to the Fleather history', + (tester) async { + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.undoManager, + (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.undoManager, null); + UndoManager.client = null; + }); + + final fixture = await _pumpEditor(tester); + final originalText = fixture.controller.document.toPlainText(); + fixture.state + .onScribbleFocus(tester.getCenter(find.byType(TextLine).first)); + await tester.pump(); + fixture.controller.replaceText(0, 0, 'X', + selection: const TextSelection.collapsed(offset: 1)); + await tester.pump(throttleDuration); + + expect(fixture.controller.canUndo, isTrue); + expect(UndoManager.client, isNotNull); + expect( + calls.any( + (call) => + call.method == 'UndoManager.setUndoState' && + (call.arguments as Map)['canUndo'] == true, + ), + isTrue, + ); + + UndoManager.client!.handlePlatformUndo(UndoDirection.undo); + expect(fixture.controller.document.toPlainText(), originalText); + expect(fixture.controller.canRedo, isTrue); + await tester.pump(); + + UndoManager.client!.handlePlatformUndo(UndoDirection.redo); + expect(fixture.controller.document.toPlainText(), 'X$originalText'); + await tester.pump(); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('reports visible character geometry to the text input connection', + (tester) async { + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.textInput, (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.textInput, null); + tester.testTextInput.register(); + }); + + final fixture = await _pumpEditor( + tester, + document: ParchmentDocument.fromDelta( + Delta() + ..insert('你😀') + ..insert('\n', ParchmentAttribute.block.bulletList.toJson()) + ..insert('A\n'), + ), + ); + fixture.state.onScribbleFocus(tester.getCenter(find.byType(RawEditor))); + await tester.pump(); + await tester.pump(); + + final geometryCalls = calls + .where((call) => call.method == 'TextInput.setSelectionRects') + .toList(); + expect(geometryCalls, isNotEmpty); + final rects = + (geometryCalls.last.arguments as List).cast>(); + final positions = rects.map((rect) => rect[4]).toList(); + expect(positions, containsAll([0, 1, 4])); + expect(positions, isNot(contains(2))); + expect( + calls.any((call) => call.method == 'TextInput.setCaretRect'), isTrue); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('renders and removes a placeholder without changing the document', + (tester) async { + final fixture = await _pumpEditor(tester); + fixture.controller + .updateSelection(const TextSelection.collapsed(offset: 3)); + await tester.pump(); + final originalDelta = fixture.controller.document.toDelta().toJson(); + + fixture.state.insertTextPlaceholder(const Size(120, 40)); + await tester.pump(); + + expect(fixture.controller.document.toDelta().toJson(), originalDelta); + expect(_linePlainText(tester).contains('\uFFFC'), isTrue); + + fixture.state.removeTextPlaceholder(); + await tester.pump(); + + expect(fixture.controller.document.toDelta().toJson(), originalDelta); + expect(_linePlainText(tester).contains('\uFFFC'), isFalse); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); +} + +String _linePlainText(WidgetTester tester) { + final richText = find + .descendant(of: find.byType(TextLine), matching: find.byType(RichText)) + .first; + return tester.widget(richText).text.toPlainText(); +} + +Future<_EditorFixture> _pumpEditor( + WidgetTester tester, { + bool stylusHandwritingEnabled = true, + bool readOnly = false, + ParchmentDocument? document, +}) async { + final controller = FleatherController( + document: document ?? + ParchmentDocument.fromDelta(Delta()..insert('Hello Scribble\n')), + ); + final focusNode = FocusNode(); + addTearDown(() { + controller.dispose(); + focusNode.dispose(); + }); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 320, + height: 200, + child: FleatherEditor( + controller: controller, + focusNode: focusNode, + expands: true, + stylusHandwritingEnabled: stylusHandwritingEnabled, + readOnly: readOnly, + ), + ), + ), + ), + ); + await tester.pump(); + return _EditorFixture( + controller: controller, + focusNode: focusNode, + state: tester.state(find.byType(RawEditor)), + ); +} + +class _EditorFixture { + const _EditorFixture( + {required this.controller, required this.focusNode, required this.state}); + + final FleatherController controller; + final FocusNode focusNode; + final RawEditorState state; +} diff --git a/packages/fleather/test/widgets/history_test.dart b/packages/fleather/test/widgets/history_test.dart index 996ed892..77bf777d 100644 --- a/packages/fleather/test/widgets/history_test.dart +++ b/packages/fleather/test/widgets/history_test.dart @@ -76,8 +76,7 @@ void main() { composing: TextRange.collapsed(26)) ]); - // Throttle time of 500ms in history - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(throttleDuration); await tester.pumpAndSettle(); expect(editor.controller.document.toDelta(), endState); @@ -108,8 +107,7 @@ void main() { await editor.pumpAndTap(); editor.controller .formatText(initialLength - 5, 5, ParchmentAttribute.italic.unset); - // Throttle time of 500ms in history - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(throttleDuration); await tester.pumpAndSettle(); expect(editor.controller.document.toDelta(), endState); @@ -167,8 +165,7 @@ void main() { await enterText(const TextEditingValue( text: 'Something in the way mmmmm', selection: TextSelection.collapsed(offset: 26))); - // Throttle time of 500ms in history - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(throttleDuration); await tester.pumpAndSettle(); var editorState = tester.state(find.byType(RawEditor));