diff --git a/MIGRATION.md b/MIGRATION.md index 0f25883b..487be362 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,3 +1,8 @@ +## Fleather 1.25.1 > 2.0.0 + +* `embedBuilder` is not supported anymore. Use `EmbedRegistry` to register embed configuration. + + ## Fleather 1.4.4 > 1.14.5+1 * Change `SelectorScope.of(context).pushSelector(selector, completer)` to `SelectorScope.showSelector(context, selector, completer)` or `SelectorScope.of(context).showSelector(context, selector, completer)` diff --git a/packages/fleather/example/assets/welcome.json b/packages/fleather/example/assets/welcome.json index bf6c112d..e233720a 100644 --- a/packages/fleather/example/assets/welcome.json +++ b/packages/fleather/example/assets/welcome.json @@ -19,7 +19,7 @@ }, { "insert": { - "_type": "image", + "_type": "block_image", "_inline": false, "source": "images/breeze.jpg", "source_type": "assets" @@ -166,7 +166,7 @@ }, { "insert": { - "_type": "image", + "_type": "span_image", "_inline": true, "source": "images/breeze.jpg", "orientation": "landscape", @@ -177,7 +177,7 @@ }, { "insert": { - "_type": "image", + "_type": "span_image", "_inline": true, "source": "images/cody-manning-82xt4MtbJqg-unsplash.jpg", "source_type": "assets", diff --git a/packages/fleather/example/ios/Podfile.lock b/packages/fleather/example/ios/Podfile.lock index 2ea5ad2d..ddcf6dc4 100644 --- a/packages/fleather/example/ios/Podfile.lock +++ b/packages/fleather/example/ios/Podfile.lock @@ -25,9 +25,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe PODFILE CHECKSUM: 0dbd5a87e0ace00c9610d2037ac22083a01f861d diff --git a/packages/fleather/example/lib/main.dart b/packages/fleather/example/lib/main.dart index 9dca5aef..2b9f5dfb 100644 --- a/packages/fleather/example/lib/main.dart +++ b/packages/fleather/example/lib/main.dart @@ -130,7 +130,9 @@ class _HomePageState extends State { ), onLaunchUrl: _launchUrl, maxContentWidth: 800, - embedBuilder: _embedBuilder, + embedRegistry: EmbedRegistry.fallbackWithConfigurations( + [IconEmbed(), ImageBlockEmbed(), ImageSpanEmbed()], + ), spellCheckConfiguration: SpellCheckConfiguration( spellCheckService: DefaultSpellCheckService(), misspelledSelectionColor: Colors.red, @@ -143,56 +145,6 @@ class _HomePageState extends State { ); } - Widget _embedBuilder(BuildContext context, EmbedNode node) { - if (node.value.type == 'icon') { - final data = node.value.data; - // Icons.rocket_launch_outlined - return Icon( - IconData(int.parse(data['codePoint']), fontFamily: data['fontFamily']), - color: Color(int.parse(data['color'])), - size: 18, - ); - } - - if (node.value.type == 'image') { - final sourceType = node.value.data['source_type']; - ImageProvider? image; - if (sourceType == 'assets') { - image = AssetImage(node.value.data['source']); - } else if (sourceType == 'file') { - image = FileImage(File(node.value.data['source'])); - } else if (sourceType == 'url') { - image = NetworkImage(node.value.data['source']); - } else if (sourceType == 'data') { - // source: 'data:image/jpeg;base64, LzlqLzRBQ... ' - RegExp regex = RegExp( - r'^data:image\/(png|jpe?g|gif|bmp|webp);base64,', - caseSensitive: false, - ); - if (regex.hasMatch(node.value.data['source'])) { - String base64Image = - node.value.data['source'].replaceFirst(regex, ''); - image = MemoryImage(base64Decode(base64Image)); - } - } - if (image != null) { - return Padding( - // Caret takes 2 pixels, hence not symmetric padding values. - padding: const EdgeInsets.only(left: 4, right: 2, top: 2, bottom: 2), - child: Container( - width: (node.value.data['width'] as num?)?.toDouble() ?? 300, - height: (node.value.data['height'] as num?)?.toDouble() ?? 300, - decoration: BoxDecoration( - image: DecorationImage(image: image, fit: BoxFit.cover), - ), - ), - ); - } - } - - return defaultFleatherEmbedBuilder(context, node); - } - void _launchUrl(String? url) async { if (url == null) return; final uri = Uri.parse(url); @@ -203,6 +155,73 @@ class _HomePageState extends State { } } +class IconEmbed extends SpanEmbedConfiguration { + const IconEmbed() + : super(key: 'icon', alignment: PlaceholderAlignment.middle); + + @override + Widget build(BuildContext context, Map data) { + // Icons.rocket_launch_outlined + return Icon( + IconData(int.parse(data['codePoint']), fontFamily: data['fontFamily']), + color: Color(int.parse(data['color'])), + size: 18, + ); + } +} + +Widget _imageBuilder(BuildContext context, Map data) { + final sourceType = data['source_type']; + ImageProvider? image; + if (sourceType == 'assets') { + image = AssetImage(data['source']); + } else if (sourceType == 'file') { + image = FileImage(File(data['source'])); + } else if (sourceType == 'url') { + image = NetworkImage(data['source']); + } else if (sourceType == 'data') { + // source: 'data:image/jpeg;base64, LzlqLzRBQ... ' + RegExp regex = RegExp( + r'^data:image\/(png|jpe?g|gif|bmp|webp);base64,', + caseSensitive: false, + ); + if (regex.hasMatch(data['source'])) { + String base64Image = data['source'].replaceFirst(regex, ''); + image = MemoryImage(base64Decode(base64Image)); + } + } + if (image == null) { + throw ArgumentError('Could create an ImageProvider from the provided data'); + } + return Padding( + // Caret takes 2 pixels, hence not symmetric padding values. + padding: const EdgeInsets.only(left: 4, right: 2, top: 2, bottom: 2), + child: Container( + width: (data['width'] as num?)?.toDouble() ?? 300, + height: (data['height'] as num?)?.toDouble() ?? 300, + decoration: BoxDecoration( + image: DecorationImage(image: image, fit: BoxFit.cover), + ), + ), + ); +} + +class ImageSpanEmbed extends SpanEmbedConfiguration { + ImageSpanEmbed() : super(key: 'span_image'); + + @override + Widget build(BuildContext context, Map data) => + _imageBuilder(context, data); +} + +class ImageBlockEmbed extends BlockEmbedConfiguration { + ImageBlockEmbed() : super(key: 'block_image'); + + @override + Widget build(BuildContext context, Map data) => + _imageBuilder(context, data); +} + /// This is an example insert rule that will insert a new line before and /// after inline image embed. class ForceNewlineForInsertsAroundInlineImageRule extends InsertRule { diff --git a/packages/fleather/lib/fleather.dart b/packages/fleather/lib/fleather.dart index 2e3f951d..2b9ea983 100644 --- a/packages/fleather/lib/fleather.dart +++ b/packages/fleather/lib/fleather.dart @@ -13,6 +13,7 @@ export 'src/widgets/controller.dart'; export 'src/widgets/cursor.dart'; export 'src/widgets/editor.dart'; export 'src/widgets/editor_toolbar.dart'; +export 'src/widgets/embed_registry.dart'; export 'src/widgets/field.dart'; export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction; export 'src/widgets/text_line.dart'; diff --git a/packages/fleather/lib/src/widgets/editable_text_block.dart b/packages/fleather/lib/src/widgets/editable_text_block.dart index 80b0928d..d9023010 100644 --- a/packages/fleather/lib/src/widgets/editable_text_block.dart +++ b/packages/fleather/lib/src/widgets/editable_text_block.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:fleather/src/widgets/embed_registry.dart'; import 'package:flutter/material.dart'; import 'package:parchment/parchment.dart'; @@ -8,7 +9,6 @@ import 'checkbox.dart'; import 'controller.dart'; import 'cursor.dart'; import 'editable_text_line.dart'; -import 'editor.dart'; import 'link.dart'; import 'text_line.dart'; import 'theme.dart'; @@ -24,7 +24,7 @@ class EditableTextBlock extends StatelessWidget { final Color selectionColor; final bool enableInteractiveSelection; final bool hasFocus; - final FleatherEmbedBuilder embedBuilder; + final EmbedRegistry embedRegistry; final LinkActionPicker linkActionPicker; final ValueChanged? onLaunchUrl; final EdgeInsets? contentPadding; @@ -41,7 +41,7 @@ class EditableTextBlock extends StatelessWidget { required this.selectionColor, required this.enableInteractiveSelection, required this.hasFocus, - required this.embedBuilder, + required this.embedRegistry, required this.linkActionPicker, this.onLaunchUrl, this.contentPadding, @@ -83,7 +83,7 @@ class EditableTextBlock extends StatelessWidget { node: line, readOnly: readOnly, controller: controller, - embedBuilder: embedBuilder, + embedRegistry: embedRegistry, linkActionPicker: linkActionPicker, onLaunchUrl: onLaunchUrl, textWidthBasis: textWidthBasis, diff --git a/packages/fleather/lib/src/widgets/editor.dart b/packages/fleather/lib/src/widgets/editor.dart index a9b82990..9c282840 100644 --- a/packages/fleather/lib/src/widgets/editor.dart +++ b/packages/fleather/lib/src/widgets/editor.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:math'; +import 'package:fleather/src/widgets/embed_registry.dart'; import 'package:flutter/cupertino.dart' hide SystemContextMenu; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -253,7 +254,7 @@ class FleatherEditor extends StatefulWidget { /// Builder function for embeddable objects. /// /// Defaults to [defaultFleatherEmbedBuilder]. - final FleatherEmbedBuilder embedBuilder; + final EmbedRegistry embedRegistry; /// Configuration that details how spell check should be performed. /// @@ -330,7 +331,7 @@ class FleatherEditor extends StatefulWidget { this.clipboardManager = const PlainTextClipboardManager(), this.clipboardStatus, this.contextMenuBuilder = defaultContextMenuBuilder, - this.embedBuilder = defaultFleatherEmbedBuilder, + this.embedRegistry = const EmbedRegistry.fallback(), this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.textSelectionControls}); @@ -504,7 +505,7 @@ class _FleatherEditorState extends State keyboardAppearance: keyboardAppearance, scrollPhysics: widget.scrollPhysics, onLaunchUrl: widget.onLaunchUrl, - embedBuilder: widget.embedBuilder, + embedRegistry: widget.embedRegistry, spellCheckConfiguration: widget.spellCheckConfiguration, linkActionPickerDelegate: widget.linkActionPickerDelegate, clipboardManager: widget.clipboardManager, @@ -617,7 +618,7 @@ class RawEditor extends StatefulWidget { this.onSelectionChanged, this.contextMenuBuilder = defaultContextMenuBuilder, this.spellCheckConfiguration, - this.embedBuilder = defaultFleatherEmbedBuilder, + this.embedRegistry = const EmbedRegistry(), this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, }) : assert(maxHeight == null || maxHeight > 0), assert(minHeight == null || minHeight >= 0), @@ -795,10 +796,7 @@ class RawEditor extends StatefulWidget { /// See [Scrollable.physics]. final ScrollPhysics? scrollPhysics; - /// Builder function for embeddable objects. - /// - /// Defaults to [defaultFleatherEmbedBuilder]. - final FleatherEmbedBuilder embedBuilder; + final EmbedRegistry embedRegistry; final LinkActionPickerDelegate linkActionPickerDelegate; @@ -929,18 +927,18 @@ class RawEditorState extends EditorState final GlobalKey _editorKey = GlobalKey(); final GlobalKey _scrollableKey = GlobalKey(); - // Theme +// Theme late FleatherThemeData _themeData; @override FleatherThemeData get themeData => _themeData; - // Cursors +// Cursors late CursorController _cursorController; FleatherController get controller => widget.controller; - // Selection overlay +// Selection overlay @override EditorTextSelectionOverlay? get selectionOverlay => _selectionOverlay; EditorTextSelectionOverlay? _selectionOverlay; @@ -1016,10 +1014,10 @@ class RawEditorState extends EditorState /// is already shown, or when no text selection currently exists. @override bool showToolbar({createIfNull = false}) { - // Web is using native dom elements to enable clipboard functionality of the - // toolbar: copy, paste, select, cut. It might also provide additional - // functionality depending on the browser (such as translate). Due to this - // we should not show a Flutter toolbar for the editable text elements. +// Web is using native dom elements to enable clipboard functionality of the +// toolbar: copy, paste, select, cut. It might also provide additional +// functionality depending on the browser (such as translate). Due to this +// we should not show a Flutter toolbar for the editable text elements. if (kIsWeb && BrowserContextMenu.enabled) { return false; } @@ -1076,10 +1074,10 @@ class RawEditorState extends EditorState @override bool showSpellCheckSuggestionsToolbar() { - // Spell check suggestions toolbars are intended to be shown on non-web - // platforms. Additionally, the Cupertino style toolbar can't be drawn on - // the web with the HTML renderer due to - // https://github.com/flutter/flutter/issues/123560. +// Spell check suggestions toolbars are intended to be shown on non-web +// platforms. Additionally, the Cupertino style toolbar can't be drawn on +// the web with the HTML renderer due to +// https://github.com/flutter/flutter/issues/123560. final bool platformNotSupported = kIsWeb && BrowserContextMenu.enabled; if (!spellCheckEnabled || platformNotSupported || @@ -1089,8 +1087,8 @@ class RawEditorState extends EditorState findSuggestionSpanAtCursorIndex( textEditingValue.selection.extentOffset) == null) { - // Only attempt to show the spell check suggestions toolbar if there - // is a toolbar specified and spell check suggestions available to show. +// Only attempt to show the spell check suggestions toolbar if there +// is a toolbar specified and spell check suggestions available to show. return false; } @@ -1143,9 +1141,9 @@ class RawEditorState extends EditorState WidgetsBinding .instance.platformDispatcher.nativeSpellCheckServiceDefined; if (spellCheckAutomaticallyDisabled || !spellCheckServiceIsConfigured) { - // Only enable spell check if a non-disabled configuration is provided - // and if that configuration does not specify a spell check service, - // a native spell checker must be supported. +// Only enable spell check if a non-disabled configuration is provided +// and if that configuration does not specify a spell check service, +// a native spell checker must be supported. assert(() { if (!spellCheckAutomaticallyDisabled && !spellCheckServiceIsConfigured) { @@ -1188,12 +1186,12 @@ class RawEditorState extends EditorState ?.fetchSpellCheckSuggestions(localeForSpellChecking!, text); if (suggestions == null) { - // The request to fetch spell check suggestions was canceled due to ongoing request. +// The request to fetch spell check suggestions was canceled due to ongoing request. return; } spellCheckResults = SpellCheckResults(text, suggestions); - // TODO : renderEditable.text = buildTextSpan(); +// TODO : renderEditable.text = buildTextSpan(); } catch (exception, stack) { FlutterError.reportError(FlutterErrorDetails( exception: exception, @@ -1208,8 +1206,8 @@ class RawEditorState extends EditorState SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) { if (!_spellCheckResultsReceived || spellCheckResults!.suggestionSpans.last.range.end < cursorIndex) { - // No spell check results have been received or the cursor index is out - // of range that suggestionSpans covers. +// No spell check results have been received or the cursor index is out +// of range that suggestionSpans covers. return null; } @@ -1256,7 +1254,7 @@ class RawEditorState extends EditorState case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - // Collapse the selection and hide the toolbar and handles. +// Collapse the selection and hide the toolbar and handles. userUpdateTextEditingValue( TextEditingValue( text: textEditingValue.text, @@ -1310,8 +1308,8 @@ class RawEditorState extends EditorState if (!selection.isValid) { return; } - // Snapshot the input before using `await`. - // See https://github.com/flutter/flutter/issues/11427 +// Snapshot the input before using `await`. +// See https://github.com/flutter/flutter/issues/11427 final data = await widget.clipboardManager.getData(); if (data == null || data.isEmpty) { return; @@ -1379,7 +1377,7 @@ class RawEditorState extends EditorState _selectionOverlay?.updateForScroll(); } - // State lifecycle: +// State lifecycle: @override void initState() { @@ -1395,18 +1393,18 @@ class RawEditorState extends EditorState _scrollController = widget.scrollController ?? ScrollController(); _scrollController.addListener(_updateSelectionOverlayForScroll); - // Cursor +// Cursor _cursorController = CursorController( showCursor: ValueNotifier(widget.showCursor), style: widget.cursorStyle, tickerProvider: this, ); - // Floating cursor +// Floating cursor _floatingCursorResetController = AnimationController(vsync: this); _floatingCursorResetController.addListener(onFloatingCursorResetTick); - // Focus +// Focus effectiveFocusNode.addListener(_handleFocusChanged); } @@ -1503,21 +1501,21 @@ class RawEditorState extends EditorState _cursorController.startOrStopCursorTimerIfNeeded( _hasFocus, widget.controller.selection); if (hasConnection) { - // To keep the cursor from blinking while typing, we want to restart the - // cursor timer every time a new character is typed. +// To keep the cursor from blinking while typing, we want to restart the +// cursor timer every time a new character is typed. _cursorController.stopCursorTimer(resetCharTicks: false); _cursorController.startCursorTimer(); } setState(() { - /* +/* * We use widget.controller.value in build(). * We need to run this before updating SelectionOverlay to ensure * that renderers are in line with the document. */ }); - // When a new document node is added or removed due to a line/block - // insertion or deletion, we must wait for next frame the ensure the - // RenderEditor's child list reflects the new document node structure +// When a new document node is added or removed due to a line/block +// insertion or deletion, we must wait for next frame the ensure the +// RenderEditor's child list reflects the new document node structure SchedulerBinding.instance.addPersistentFrameCallback((timeStamp) { _updateOrDisposeSelectionOverlayIfNeeded(); }); @@ -1543,13 +1541,13 @@ class RawEditorState extends EditorState _selectionOverlay!.showHandles(); } - // This will show the keyboard for all selection changes on the - // editor, not just changes triggered by user gestures. +// This will show the keyboard for all selection changes on the +// editor, not just changes triggered by user gestures. requestKeyboard(); if (cause == SelectionChangedCause.drag) { - // When user updates the selection while dragging make sure to - // bring the updated position (base or extent) into view. +// When user updates the selection while dragging make sure to +// bring the updated position (base or extent) into view. if (oldSelection.baseOffset != selection.baseOffset) { bringIntoView(selection.base); } else if (oldSelection.extentOffset != selection.extentOffset) { @@ -1583,28 +1581,28 @@ class RawEditorState extends EditorState _hasFocus, widget.controller.selection); _updateOrDisposeSelectionOverlayIfNeeded(); if (_hasFocus) { - // Listen for changing viewInsets, which indicates keyboard showing up. +// Listen for changing viewInsets, which indicates keyboard showing up. WidgetsBinding.instance.addObserver(this); _lastBottomViewInset = View.of(context).viewInsets.bottom; _showCaretOnScreen(); // _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom; // if (!_value.selection.isValid) { - // Place cursor at the end if the selection is invalid when we receive focus. +// Place cursor at the end if the selection is invalid when we receive focus. // _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null); // } } else { WidgetsBinding.instance.removeObserver(this); - // TODO: teach editor about state of the toolbar and whether the user is in the middle of applying styles. - // this is needed because some buttons in toolbar can steal focus from the editor - // but we want to preserve the selection, maybe adjusting its style slightly. - // - // Clear the selection and composition state if this widget lost focus. - // widget.controller.updateSelection(TextSelection.collapsed(offset: 0), - // source: ChangeSource.local); +// TODO: teach editor about state of the toolbar and whether the user is in the middle of applying styles. +// this is needed because some buttons in toolbar can steal focus from the editor +// but we want to preserve the selection, maybe adjusting its style slightly. +// +// Clear the selection and composition state if this widget lost focus. +// widget.controller.updateSelection(TextSelection.collapsed(offset: 0), +// source: ChangeSource.local); // _currentPromptRectRange = null; } setState(() { - // Inform the widget that the value of focus has changed. (so that cursor can repaint appropriately) +// Inform the widget that the value of focus has changed. (so that cursor can repaint appropriately) }); updateKeepAlive(); } @@ -1620,7 +1618,7 @@ class RawEditorState extends EditorState } } - // Animation configuration for scrolling the caret back on screen. +// Animation configuration for scrolling the caret back on screen. static const Duration _caretAnimationDuration = Duration(milliseconds: 100); static const Curve _caretAnimationCurve = Curves.fastOutSlowIn; @@ -1678,7 +1676,7 @@ class RawEditorState extends EditorState void _onChangedClipboardStatus() { if (mounted) { - // Inform the widget that the value of clipboardStatus has changed. +// Inform the widget that the value of clipboardStatus has changed. setState(() {}); } } @@ -1712,16 +1710,16 @@ class RawEditorState extends EditorState _selectionOverlay?.updateForScroll(); }); if (_lastBottomViewInset < bottomViewInset) { - // Because the metrics change signal from engine will come here every frame - // (on both iOS and Android). So we don't need to show caret with animation. +// Because the metrics change signal from engine will come here every frame +// (on both iOS and Android). So we don't need to show caret with animation. _showCaretOnScreen(false); } } _lastBottomViewInset = bottomViewInset; } - // On MacOS some actions are sent as selectors. We need to manually find the right Action and invoke it. - // Ref: https://github.com/flutter/flutter/blob/3.7.0/packages/flutter/lib/src/widgets/editable_text.dart#L3731 +// On MacOS some actions are sent as selectors. We need to manually find the right Action and invoke it. +// Ref: https://github.com/flutter/flutter/blob/3.7.0/packages/flutter/lib/src/widgets/editable_text.dart#L3731 @override void performSelector(String selectorName) { final Intent? intent = intentForMacOSSelector(selectorName); @@ -1842,7 +1840,7 @@ class RawEditorState extends EditorState node: node, readOnly: widget.readOnly, controller: widget.controller, - embedBuilder: widget.embedBuilder, + embedRegistry: widget.embedRegistry, linkActionPicker: _linkActionPicker, onLaunchUrl: widget.onLaunchUrl, textWidthBasis: widget.textWidthBasis, @@ -1869,7 +1867,7 @@ class RawEditorState extends EditorState contentPadding: (block == ParchmentAttribute.block.code) ? const EdgeInsets.all(16.0) : null, - embedBuilder: widget.embedBuilder, + embedRegistry: widget.embedRegistry, linkActionPicker: _linkActionPicker, onLaunchUrl: widget.onLaunchUrl, ), @@ -1915,7 +1913,7 @@ class RawEditorState extends EditorState } } - // --------------------------- Text Editing Actions --------------------------- +// --------------------------- Text Editing Actions --------------------------- _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { final _TextBoundary atomicTextBoundary = @@ -1927,18 +1925,18 @@ class RawEditorState extends EditorState final _TextBoundary atomicTextBoundary; final _TextBoundary boundary; - // final TextEditingValue textEditingValue = - // _textEditingValueforTextLayoutMetrics; +// final TextEditingValue textEditingValue = +// _textEditingValueforTextLayoutMetrics; atomicTextBoundary = _CharacterBoundary(textEditingValue); - // This isn't enough. Newline characters. +// This isn't enough. Newline characters. boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), _WordBoundary(renderEditor, textEditingValue)); final _MixedBoundary mixedBoundary = intent.forward ? _MixedBoundary(atomicTextBoundary, boundary) : _MixedBoundary(boundary, atomicTextBoundary); - // Use a _MixedBoundary to make sure we don't leave invalid codepoints in - // the field after deletion. +// Use a _MixedBoundary to make sure we don't leave invalid codepoints in +// the field after deletion. return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); } @@ -1946,16 +1944,16 @@ class RawEditorState extends EditorState final _TextBoundary atomicTextBoundary; final _TextBoundary boundary; - // final TextEditingValue textEditingValue = - // _textEditingValueforTextLayoutMetrics; +// final TextEditingValue textEditingValue = +// _textEditingValueforTextLayoutMetrics; atomicTextBoundary = _CharacterBoundary(textEditingValue); boundary = _LineBreak(renderEditor, textEditingValue); - // The _MixedBoundary is to make sure we don't leave invalid code units in - // the field after deletion. - // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, - // since the document boundary is unique and the linebreak boundary is - // already caret-location based. +// The _MixedBoundary is to make sure we don't leave invalid code units in +// the field after deletion. +// `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, +// since the document boundary is unique and the linebreak boundary is +// already caret-location based. return intent.forward ? _MixedBoundary( _CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) @@ -1969,8 +1967,8 @@ class RawEditorState extends EditorState _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => _DocumentBoundary(textEditingValue); - // Scrolls either to the beginning or end of the document depending on the - // intent's `forward` parameter. +// Scrolls either to the beginning or end of the document depending on the +// intent's `forward` parameter. void _scrollToDocumentBoundary(ScrollToDocumentBoundaryIntent intent) { if (intent.forward) { bringIntoView(TextPosition(offset: textEditingValue.text.length)); @@ -1987,8 +1985,8 @@ class RawEditorState extends EditorState } final ScrollPosition position = _scrollController.position; - // If the field isn't scrollable, do nothing. For example, when the lines of - // text is less than maxLines, the field has nothing to scroll. +// If the field isn't scrollable, do nothing. For example, when the lines of +// text is less than maxLines, the field has nothing to scroll. if (position.maxScrollExtent == 0.0 && position.minScrollExtent == 0.0) { return; } @@ -2045,7 +2043,7 @@ class RawEditorState extends EditorState UpdateSelectionIntent: _updateSelectionAction, DirectionalFocusIntent: DirectionalFocusAction.forTextField(), - // Delete +// Delete DeleteCharacterIntent: _makeOverridable( _DeleteTextAction(this, _characterBoundary)), DeleteToNextWordBoundaryIntent: _makeOverridable( @@ -2054,7 +2052,7 @@ class RawEditorState extends EditorState DeleteToLineBreakIntent: _makeOverridable( _DeleteTextAction(this, _linebreak)), - // Extend/Move Selection +// Extend/Move Selection ExtendSelectionByCharacterIntent: _makeOverridable( _UpdateTextSelectionAction( this, @@ -2090,7 +2088,7 @@ class RawEditorState extends EditorState onInvoke: _scrollToDocumentBoundary)), ScrollIntent: CallbackAction(onInvoke: _scroll), - // Copy Paste +// Copy Paste SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), PasteTextIntent: _makeOverridable(CallbackAction( @@ -2099,12 +2097,12 @@ class RawEditorState extends EditorState @override void insertTextPlaceholder(Size size) { - // TODO: implement insertTextPlaceholder +// TODO: implement insertTextPlaceholder } @override void removeTextPlaceholder() { - // TODO: implement removeTextPlaceholder +// TODO: implement removeTextPlaceholder } /// Returns the anchor points for the default context menu. @@ -2115,7 +2113,7 @@ class RawEditorState extends EditorState primaryAnchor: renderEditor.lastSecondaryTapDownPosition!); } final selection = textEditingValue.selection; - // Find the horizontal midpoint, just above the selected text. +// Find the horizontal midpoint, just above the selected text. final List endpoints = renderEditor.getEndpointsForSelection(selection); @@ -2134,8 +2132,8 @@ class RawEditorState extends EditorState required double endGlyphHeight, required List selectionEndpoints, }) { - // If editor is scrollable, the editing region is only the viewport - // otherwise use editor as editing region +// If editor is scrollable, the editing region is only the viewport +// otherwise use editor as editing region final paintOffset = renderEditor.paintOffset; final Rect editingRegion = Rect.fromPoints( renderEditor.localToGlobal(Offset.zero), @@ -2422,7 +2420,7 @@ class _WordBoundary extends _TextBoundary { TextPosition getLeadingTextBoundaryAt(TextPosition position) { return TextPosition( offset: textLayout.getWordBoundary(position).start, - // Word boundary seems to always report downstream on many platforms. +// Word boundary seems to always report downstream on many platforms. affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values ); @@ -2432,7 +2430,7 @@ class _WordBoundary extends _TextBoundary { TextPosition getTrailingTextBoundaryAt(TextPosition position) { return TextPosition( offset: textLayout.getWordBoundary(position).end, - // Word boundary seems to always report downstream on many platforms. +// Word boundary seems to always report downstream on many platforms. affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values ); @@ -2824,7 +2822,7 @@ class _UpdateTextSelectionAction final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled; - // Collapse to the logical start/end. +// Collapse to the logical start/end. TextSelection collapse(TextSelection selection) { assert(selection.isValid); assert(!selection.isCollapsed); @@ -2869,7 +2867,7 @@ class _UpdateTextSelectionAction ? TextSelection.fromPosition(newExtent) : textBoundarySelection.extendTo(newExtent); - // If collapseAtReversal is true and would have an effect, collapse it. +// If collapseAtReversal is true and would have an effect, collapse it. if (!selection.isCollapsed && intent.collapseAtReversal && (selection.baseOffset < selection.extentOffset != diff --git a/packages/fleather/lib/src/widgets/embed_registry.dart b/packages/fleather/lib/src/widgets/embed_registry.dart new file mode 100644 index 00000000..59464a02 --- /dev/null +++ b/packages/fleather/lib/src/widgets/embed_registry.dart @@ -0,0 +1,143 @@ +import 'package:fleather/fleather.dart'; +import 'package:flutter/material.dart'; + +/// A registry fpr embeds. +/// +/// Implementers should register [EmbedConfiguration]s in [EmbedRegistry] to +/// specify how to render an embed. +class EmbedRegistry { + const EmbedRegistry._(this._registry); + + /// An empty registry + const EmbedRegistry() : this._(const {}); + + /// The default Fleather [EmbedRegistry] + /// + /// Contains only [HorizontalRule] + const EmbedRegistry.fallback() : this._(const {'hr': HorizontalRule()}); + + /// Creates a registry with a list of [EmbedConfiguration]s. + factory EmbedRegistry.withConfigurations(List configs) { + EmbedRegistry registry = EmbedRegistry(); + return registry._registerAll(configs); + } + + /// Creates a registry with a list of [EmbedConfiguration]s merged with the + /// fallback configurations. + factory EmbedRegistry.fallbackWithConfigurations( + List configs) { + EmbedRegistry registry = EmbedRegistry.fallback(); + return registry._registerAll(configs); + } + + final Map _registry; + + EmbedRegistry _registerAll(List configs) { + var registry = this; + for (final c in configs) { + registry = registry._register(c); + } + return registry; + } + + EmbedRegistry _register(EmbedConfiguration config) { + if (_registry.containsKey(config.key)) { + throw ArgumentError('${config.key} was already registered'); + } + return EmbedRegistry._(Map.from(_registry)..[config.key] = config); + } + + /// Retrieve from registry the [SpanEmbedConfiguration] corresponding to a + /// [EmbeddableObject]. + /// + /// Used by widget to render the embed in the editor. + SpanEmbedConfiguration spanEmbed(EmbeddableObject node) { + assert(node.inline, 'EmbeddableObject must be inline for SpanEmbeds'); + final embed = _registry[node.type]; + if (embed == null) { + throw StateError( + '${node.type} was not registered. Make sure to register an embed ' + 'in the EmbedRegistry', + ); + } + assert(embed is SpanEmbedConfiguration, + 'Registered embed for ${node.type} is a ${embed.runtimeType}. Expecting a SpanEmbed'); + return embed as SpanEmbedConfiguration; + } + + /// Retrieve from registry the [BlockEmbedConfiguration] corresponding to a + /// [EmbeddableObject]. + /// + /// Used by widget to render the embed in the editor. + BlockEmbedConfiguration blockEmbed(EmbeddableObject node) { + assert(!node.inline, 'EmbeddableObject may not be inline for BlockEmbeds'); + final embed = _registry[node.type]; + if (embed == null) { + throw StateError( + '${node.type} was not registered. Make sure to register an embed ' + 'in the EmbedRegistry', + ); + } + assert( + embed is BlockEmbedConfiguration, + 'Registered embed for ${node.type} is a ${embed.runtimeType}. ' + 'Expecting a BlocEmbed'); + return _registry[node.type] as BlockEmbedConfiguration; + } +} + +sealed class EmbedConfiguration { + /// The key of the embed + /// + /// Used to identify the the configuration in the [EmbedRegistry] + final String key; + + const EmbedConfiguration({required this.key}); + + /// Builds the [Widget] according to the supplied [data]. + Widget build(BuildContext context, Map data); +} + +/// [EmbedConfiguration] for block embeds +abstract class BlockEmbedConfiguration extends EmbedConfiguration { + const BlockEmbedConfiguration({required super.key}); +} + +/// [EmbedConfiguration] for span embeds +abstract class SpanEmbedConfiguration extends EmbedConfiguration { + const SpanEmbedConfiguration( + {required super.key, + this.alignment = PlaceholderAlignment.bottom, + this.baseline, + this.style}); + + /// The [PlaceholderAlignment] that should be passed to the [WidgetSpan] + /// + /// See [PlaceholderSpan.alignment] + final PlaceholderAlignment alignment; + + /// The optional [TextBaseline] that should be passed to the [WidgetSpan] + /// + /// See [PlaceholderSpan.baseline] + final TextBaseline? baseline; + + /// The optional [TextStyle] that should be passed to the [WidgetSpan] + /// + /// See [PlaceholderSpan.style] + final TextStyle? style; +} + +/// Horizontal rule [BlockEmbedConfiguration] +class HorizontalRule extends BlockEmbedConfiguration { + const HorizontalRule() : super(key: 'hr'); + + @override + Widget build(BuildContext context, Map data) { + final fleatherThemeData = FleatherTheme.of(context)!; + return Divider( + height: fleatherThemeData.horizontalRule.height, + thickness: fleatherThemeData.horizontalRule.thickness, + color: fleatherThemeData.horizontalRule.color, + ); + } +} diff --git a/packages/fleather/lib/src/widgets/field.dart b/packages/fleather/lib/src/widgets/field.dart index de8080eb..e6cf9c9b 100644 --- a/packages/fleather/lib/src/widgets/field.dart +++ b/packages/fleather/lib/src/widgets/field.dart @@ -1,3 +1,4 @@ +import 'package:fleather/src/widgets/embed_registry.dart'; import 'package:flutter/material.dart'; import 'package:parchment/parchment.dart'; @@ -167,10 +168,7 @@ class FleatherField extends StatefulWidget { /// If this configuration is left null, then spell check is disabled by default. final SpellCheckConfiguration? spellCheckConfiguration; - /// Builder function for embeddable objects. - /// - /// Defaults to [defaultFleatherEmbedBuilder]. - final FleatherEmbedBuilder embedBuilder; + final EmbedRegistry embedRegistry; /// Builds the text selection toolbar when requested by the user. /// @@ -211,7 +209,7 @@ class FleatherField extends StatefulWidget { this.toolbar, this.contextMenuBuilder = defaultContextMenuBuilder, this.spellCheckConfiguration, - this.embedBuilder = defaultFleatherEmbedBuilder, + this.embedRegistry = const EmbedRegistry.fallback(), this.clipboardManager = const PlainTextClipboardManager(), }); @@ -280,7 +278,7 @@ class _FleatherFieldState extends State { keyboardAppearance: keyboardAppearance, scrollPhysics: widget.scrollPhysics, onLaunchUrl: widget.onLaunchUrl, - embedBuilder: widget.embedBuilder, + embedRegistry: widget.embedRegistry, spellCheckConfiguration: widget.spellCheckConfiguration, contextMenuBuilder: widget.contextMenuBuilder, clipboardManager: widget.clipboardManager, diff --git a/packages/fleather/lib/src/widgets/text_line.dart b/packages/fleather/lib/src/widgets/text_line.dart index 7cf3e2e2..d0dcefc4 100644 --- a/packages/fleather/lib/src/widgets/text_line.dart +++ b/packages/fleather/lib/src/widgets/text_line.dart @@ -1,3 +1,4 @@ +import 'package:fleather/src/widgets/embed_registry.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -7,7 +8,6 @@ import 'package:parchment/parchment.dart'; import 'controller.dart'; import 'editable_text_line.dart'; -import 'editor.dart'; import 'embed_proxy.dart'; import 'keyboard_listener.dart'; import 'link.dart'; @@ -23,7 +23,7 @@ class TextLine extends StatefulWidget { final LineNode node; final bool readOnly; final FleatherController controller; - final FleatherEmbedBuilder embedBuilder; + final EmbedRegistry embedRegistry; final ValueChanged? onLaunchUrl; final LinkActionPicker linkActionPicker; final TextWidthBasis textWidthBasis; @@ -33,7 +33,7 @@ class TextLine extends StatefulWidget { required this.node, required this.readOnly, required this.controller, - required this.embedBuilder, + required this.embedRegistry, required this.onLaunchUrl, required this.linkActionPicker, required this.textWidthBasis, @@ -123,7 +123,13 @@ class _TextLineState extends State { assert(debugCheckHasMediaQuery(context)); if (widget.node.hasBlockEmbed) { final embed = widget.node.children.single as EmbedNode; - return EmbedProxy(child: widget.embedBuilder(context, embed)); + final blocEmbed = widget.embedRegistry.blockEmbed(embed.value); + return EmbedProxy( + child: blocEmbed.build( + context, + embed.value.data, + ), + ); } final theme = FleatherTheme.of(context)!; final text = buildText(context, widget.node, theme); @@ -174,8 +180,14 @@ class _TextLineState extends State { InlineSpan _segmentToTextSpan(Node segment, FleatherThemeData theme) { if (segment is EmbedNode) { + final spanEmbed = widget.embedRegistry.spanEmbed(segment.value); return WidgetSpan( - child: EmbedProxy(child: widget.embedBuilder(context, segment))); + child: EmbedProxy( + child: spanEmbed.build(context, segment.value.data), + ), + alignment: spanEmbed.alignment, + baseline: spanEmbed.baseline, + style: spanEmbed.style); } final text = segment as TextNode; final attrs = text.style; diff --git a/packages/fleather/test/testing.dart b/packages/fleather/test/testing.dart index 96d8bbbf..0769ead9 100644 --- a/packages/fleather/test/testing.dart +++ b/packages/fleather/test/testing.dart @@ -22,7 +22,7 @@ class EditorSandBox { FakeSpellCheckService? spellCheckService, TextWidthBasis textWidthBasis = TextWidthBasis.parent, ClipboardManager clipboardManager = const PlainTextClipboardManager(), - FleatherEmbedBuilder embedBuilder = defaultFleatherEmbedBuilder, + EmbedRegistry embedRegistry = const EmbedRegistry.fallback(), TransitionBuilder? appBuilder, }) { focusNode ??= FocusNode(); @@ -40,7 +40,7 @@ class EditorSandBox { enableSelectionInteraction: enableSelectionInteraction, textWidthBasis: textWidthBasis, spellCheckService: spellCheckService, - embedBuilder: embedBuilder, + embedRegistry: embedRegistry, clipboardManager: clipboardManager, ); @@ -158,7 +158,7 @@ class _FleatherSandbox extends StatefulWidget { this.enableSelectionInteraction = true, this.spellCheckService, required this.textWidthBasis, - this.embedBuilder = defaultFleatherEmbedBuilder, + this.embedRegistry = const EmbedRegistry.fallback(), this.clipboardManager = const PlainTextClipboardManager(), }); @@ -171,7 +171,7 @@ class _FleatherSandbox extends StatefulWidget { final bool scrollable; final bool enableSelectionInteraction; final FakeSpellCheckService? spellCheckService; - final FleatherEmbedBuilder embedBuilder; + final EmbedRegistry embedRegistry; final ClipboardManager clipboardManager; final TextWidthBasis textWidthBasis; @@ -191,7 +191,7 @@ class _FleatherSandboxState extends State<_FleatherSandbox> { child: widget.useField ? FleatherField( clipboardManager: widget.clipboardManager, - embedBuilder: widget.embedBuilder, + embedRegistry: widget.embedRegistry, controller: widget.controller, focusNode: widget.focusNode, readOnly: !_enabled, @@ -208,7 +208,7 @@ class _FleatherSandboxState extends State<_FleatherSandbox> { ) : FleatherEditor( clipboardManager: widget.clipboardManager, - embedBuilder: widget.embedBuilder, + embedRegistry: widget.embedRegistry, controller: widget.controller, focusNode: widget.focusNode, readOnly: !_enabled, diff --git a/packages/fleather/test/widgets/editor_test.dart b/packages/fleather/test/widgets/editor_test.dart index 70f23e45..fab6c7fc 100644 --- a/packages/fleather/test/widgets/editor_test.dart +++ b/packages/fleather/test/widgets/editor_test.dart @@ -131,10 +131,9 @@ void main() { Expanded( child: FleatherEditor( controller: controller, - embedBuilder: (context, node) => SizedBox( - width: 100, - height: embedHeight, - ), + embedRegistry: EmbedRegistry.withConfigurations([ + FakeImageBlockEmbedConfiguration(embedHeight: embedHeight), + ]), ), ), ], @@ -185,9 +184,10 @@ void main() { child: FleatherEditor( controller: controller, scrollController: scrollController, - embedBuilder: (context, node) => SizedBox( - width: 100, - height: embedHeight, + embedRegistry: EmbedRegistry.withConfigurations( + [ + FakeImageBlockEmbedConfiguration(embedHeight: embedHeight) + ], ), ), ), @@ -1319,18 +1319,9 @@ void main() { tester: tester, document: document, autofocus: true, - embedBuilder: (BuildContext context, EmbedNode node) { - if (node.value.type == 'icon') { - final data = node.value.data; - return Icon( - IconData(int.parse(data['codePoint']), - fontFamily: data['fontFamily']), - color: Color(int.parse(data['color'])), - size: 100, - ); - } - throw UnimplementedError(); - }, + embedRegistry: EmbedRegistry.withConfigurations( + [IconSpanEmbedConfiguration()], + ), ); await editor.pump(); editor.controller.updateSelection( @@ -1371,18 +1362,9 @@ void main() { tester: tester, document: document, autofocus: true, - embedBuilder: (BuildContext context, EmbedNode node) { - if (node.value.type == 'something') { - return const Padding( - padding: EdgeInsets.only(left: 4, right: 2, top: 2, bottom: 2), - child: SizedBox( - width: 300, - height: 300, - ), - ); - } - throw UnimplementedError(); - }, + embedRegistry: EmbedRegistry.withConfigurations( + [FakeSpanEmbedConfiguration()], + ), ); await editor.pump(); editor.controller.updateSelection( @@ -1416,18 +1398,9 @@ void main() { tester: tester, document: document, autofocus: true, - embedBuilder: (BuildContext context, EmbedNode node) { - if (node.value.type == 'icon') { - final data = node.value.data; - return Icon( - IconData(int.parse(data['codePoint']), - fontFamily: data['fontFamily']), - color: Color(int.parse(data['color'])), - size: 100, - ); - } - throw UnimplementedError(); - }, + embedRegistry: EmbedRegistry.withConfigurations( + [IconSpanEmbedConfiguration()], + ), ); await editor.pump(); editor.controller @@ -1768,6 +1741,49 @@ void main() { }); } +class FakeImageBlockEmbedConfiguration extends BlockEmbedConfiguration { + const FakeImageBlockEmbedConfiguration({required this.embedHeight}) + : super(key: 'image'); + + final double embedHeight; + + @override + Widget build(BuildContext context, Map data) { + return SizedBox( + width: 100, + height: embedHeight, + ); + } +} + +class FakeSpanEmbedConfiguration extends SpanEmbedConfiguration { + const FakeSpanEmbedConfiguration() : super(key: 'something'); + + @override + Widget build(BuildContext context, Map data) { + return const Padding( + padding: EdgeInsets.only(left: 4, right: 2, top: 2, bottom: 2), + child: SizedBox( + width: 300, + height: 300, + ), + ); + } +} + +class IconSpanEmbedConfiguration extends SpanEmbedConfiguration { + IconSpanEmbedConfiguration() : super(key: 'icon'); + + @override + Widget build(BuildContext context, Map data) { + return Icon( + IconData(int.parse(data['codePoint']), fontFamily: data['fontFamily']), + color: Color(int.parse(data['color'])), + size: 100, + ); + } +} + const clipboardText = 'copied text'; void prepareClipboard() { diff --git a/packages/fleather/test/widgets/embed_registry_test.dart b/packages/fleather/test/widgets/embed_registry_test.dart new file mode 100644 index 00000000..9ca25b9b --- /dev/null +++ b/packages/fleather/test/widgets/embed_registry_test.dart @@ -0,0 +1,100 @@ +import 'package:fleather/fleather.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('$EmbedRegistry', () { + test('create fallback', () { + final registry = EmbedRegistry.fallback(); + final hr = registry.blockEmbed(EmbeddableObject('hr', inline: false)); + expect(hr, isA()); + }); + + test('create with configurations', () { + final registry = EmbedRegistry.withConfigurations([FakeBlockEmbed()]); + final fakeBlock = + registry.blockEmbed(EmbeddableObject('fake_block', inline: false)); + expect(fakeBlock, isA()); + }); + + test('create fallback with configurations', () { + final registry = + EmbedRegistry.fallbackWithConfigurations([FakeBlockEmbed()]); + final fakeBlock = + registry.blockEmbed(EmbeddableObject('fake_block', inline: false)); + expect(fakeBlock, isA()); + final hr = registry.blockEmbed(EmbeddableObject('hr', inline: false)); + expect(hr, isA()); + }); + + test('cannot register twice the same key', () { + try { + EmbedRegistry.withConfigurations([FakeBlockEmbed(), FakeBlockEmbed()]); + } on ArgumentError catch (_) { + return; + } + fail('Should throw an argument error'); + }); + + test('spanEmbed - find nothing', () { + final registry = EmbedRegistry(); + try { + registry.spanEmbed(EmbeddableObject('fake_span', inline: true)); + } on StateError catch (_) { + return; + } + fail('Should throw an assertion error'); + }); + + test('spanEmbed - finds a block embed', () { + final registry = + EmbedRegistry.fallbackWithConfigurations([FakeBlockEmbed()]); + try { + registry + .spanEmbed(EmbeddableObject(FakeBlockEmbed().key, inline: true)); + } on AssertionError catch (_) { + return; + } + fail('Should throw an assertion error'); + }); + + test('blockEmbed - find nothing', () { + final registry = EmbedRegistry(); + try { + registry.blockEmbed(EmbeddableObject('fake_block', inline: false)); + } on StateError catch (_) { + return; + } + fail('Should throw an assertion error'); + }); + + test('blockEmbed - finds a span embed', () { + final registry = EmbedRegistry.withConfigurations([FakeSpanEmbed()]); + try { + registry + .blockEmbed(EmbeddableObject(FakeSpanEmbed().key, inline: false)); + } on AssertionError catch (_) { + return; + } + fail('Should throw an assertion error'); + }); + }); +} + +class FakeBlockEmbed extends BlockEmbedConfiguration { + FakeBlockEmbed() : super(key: 'fake_block'); + + @override + Widget build(BuildContext context, Map data) { + return Text('Span'); + } +} + +class FakeSpanEmbed extends SpanEmbedConfiguration { + FakeSpanEmbed() : super(key: 'fake_span'); + + @override + Widget build(BuildContext context, Map data) { + return Text('Block'); + } +}