diff --git a/doc/placeholders_introduction.md b/doc/placeholders_introduction.md new file mode 100644 index 000000000..e0d950eb5 --- /dev/null +++ b/doc/placeholders_introduction.md @@ -0,0 +1,77 @@ +# ✏️ Placeholders + +> A **placeholder** is visible text that serves as a guide or instruction for the user when a text field or editable area is empty. + +In **Flutter Quill**, there are three scenarios where **placeholders** can appear: + +- When the document is empty, a placeholder will appear at the beginning. +- When a dynamic placeholder is configured, it can display custom guide text anywhere in the document. +- When a cursor placeholder is set up, it appears whenever a line is completely empty. + +### 💡 How to Display a Placeholder When the Document is Empty + +To configure this, you need to use the `QuillEditorConfig` class: + +```dart +final config = QuillEditorConfig( + placeholder: 'Start writing your notes...', +); +``` + +### 🔎 What Are Dynamic Placeholders? + +**Dynamic placeholders** are not static or predetermined; they are **generated or adjusted automatically** based on the context of the content or the attributes applied to the text in the editor. + +These **placeholders** appear only when specific conditions are met, such as: + +- The block is empty. +- No additional text attributes (e.g., styles or links) are applied. + +#### 🛠️ How to Create a Dynamic Placeholder + +Here's a simple example: + +If you want to display guide text only when a header is applied and the line with this attribute is empty, you can use the following code: + +```dart +final config = QuillEditorConfig( + placeholderConfig: PlaceholderConfig( + builders: { + Attribute.header.key: (Attribute attr, TextStyle style) { + // In this case, we will only support header levels h1 to h3 + final values = [30, 27, 22]; + final level = attr.value as int?; + if (level == null) return null; + final fontSize = values[(level - 1 < 0 || level - 1 > 3 ? 0 : level - 1)]; + return PlaceholderTextBuilder( + text: 'Header $level', + style: TextStyle( + fontSize: fontSize.toDouble()) + .merge(style.copyWith( + color: Colors.grey)), + ); + }, + ), +); +``` + +### 🔎 What Are Cursor Placeholders? + +**Cursor placeholders** appear when a line is completely empty and has no attributes applied. These placeholders automatically appear at the same level as the cursor, though their position can be adjusted using the `offset` parameter in the `CursorPlaceholderConfig` class. + +Here's a simple implementation example: + +```dart +final config = QuillEditorConfig( + cursorPlaceholderConfig: CursorPlaceholderConfig( + text: 'Write something...', + textStyle: TextStyle( + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + show: true, + offset: Offset(3.5, 2), + ), +); +``` + diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 9f8dd2e5e..15735c1ba 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -18,6 +18,7 @@ export 'src/document/style.dart'; export 'src/editor/editor.dart'; export 'src/editor/embed/embed_editor_builder.dart'; export 'src/editor/raw_editor/builders/leading_block_builder.dart'; +export 'src/editor/raw_editor/builders/placeholder/placeholder_configuration.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'; @@ -25,6 +26,7 @@ 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/cursor_configuration/cursor_configuration.dart'; export 'src/editor/widgets/default_styles.dart'; export 'src/editor/widgets/link.dart'; export 'src/editor/widgets/text/utils/text_block_utils.dart'; diff --git a/lib/src/common/extensions/nodes_ext.dart b/lib/src/common/extensions/nodes_ext.dart new file mode 100644 index 000000000..5796735b8 --- /dev/null +++ b/lib/src/common/extensions/nodes_ext.dart @@ -0,0 +1,10 @@ +import '../../document/nodes/node.dart'; + +extension NodesCheckingExtension on Node { + bool isNodeInline() { + for (final attr in style.attributes.values) { + if (!attr.isInline) return false; + } + return true; + } +} diff --git a/lib/src/editor/config/editor_config.dart b/lib/src/editor/config/editor_config.dart index 72459d440..4d6bae300 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -8,9 +8,11 @@ import '../../document/nodes/node.dart'; import '../../toolbar/theme/quill_dialog_theme.dart'; import '../embed/embed_editor_builder.dart'; import '../raw_editor/builders/leading_block_builder.dart'; +import '../raw_editor/builders/placeholder/placeholder_configuration.dart'; import '../raw_editor/config/events/events.dart'; import '../raw_editor/config/raw_editor_config.dart'; import '../raw_editor/raw_editor.dart'; +import '../widgets/cursor_configuration/cursor_configuration.dart'; import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; import '../widgets/link.dart'; @@ -25,6 +27,8 @@ class QuillEditorConfig { const QuillEditorConfig({ this.scrollable = true, this.padding = EdgeInsets.zero, + @experimental this.placeholderConfig, + @experimental this.cursorPlaceholderConfig, @experimental this.characterShortcutEvents = const [], @experimental this.spaceShortcutEvents = const [], this.autoFocus = false, @@ -135,6 +139,40 @@ class QuillEditorConfig { @experimental final List spaceShortcutEvents; + /// Configuration for displaying placeholders in empty lines or near the cursor. + /// + /// ### Example + /// + /// To show a placeholder text specifically for header items: + /// + /// ```dart + /// final configuration = PlaceholderConfig( + /// builders: { + /// Attribute.header.key: (Attribute attr, style) { + /// final values = [30, 27, 22]; + /// final level = attr.value as int?; + /// if (level == null) return null; + /// final fontSize = values[(level - 1 < 0 || level - 1 > 3 ? 0 : level - 1)]; + /// return PlaceholderTextBuilder( + /// text: 'Header $level', + /// style: TextStyle(fontSize: fontSize.toDouble()).merge(style), + /// ); + /// }, + /// }, + /// // If using custom attributes, register their keys here. + /// customBlockAttributesKeys: null, + /// ); + /// ``` + @experimental + final PlaceholderConfig? placeholderConfig; + + /// Configures how a placeholder is displayed relative to the cursor. + /// + /// This argument specifies the appearance, style, and position of a placeholder + /// shown at the cursor's location in an empty line. + @experimental + final CursorPlaceholderConfig? cursorPlaceholderConfig; + /// A handler for keys that are pressed when the editor is focused. /// /// This feature is supported on **desktop devices only**. @@ -501,7 +539,9 @@ class QuillEditorConfig { ContentInsertionConfiguration? contentInsertionConfiguration, GlobalKey? editorKey, TextSelectionThemeData? textSelectionThemeData, + PlaceholderConfig? placeholderConfig, bool? requestKeyboardFocusOnCheckListChanged, + CursorPlaceholderConfig? cursorPlaceholderConfig, TextInputAction? textInputAction, bool? enableScribble, void Function()? onScribbleActivated, @@ -509,6 +549,9 @@ class QuillEditorConfig { void Function(TextInputAction action)? onPerformAction, }) { return QuillEditorConfig( + cursorPlaceholderConfig: + cursorPlaceholderConfig ?? this.cursorPlaceholderConfig, + placeholderConfig: placeholderConfig ?? this.placeholderConfig, customLeadingBlockBuilder: customLeadingBlockBuilder ?? this.customLeadingBlockBuilder, placeholder: placeholder ?? this.placeholder, diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index e9fa525aa..8a3911586 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -16,6 +16,7 @@ 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/builders/placeholder/placeholder_builder.dart'; import 'raw_editor/config/raw_editor_config.dart'; import 'raw_editor/raw_editor.dart'; import 'widgets/box.dart'; @@ -253,13 +254,17 @@ class QuillEditorState extends State final showSelectionToolbar = configurations.enableInteractiveSelection && configurations.enableSelectionToolbar; - + final placeholderBuilder = widget.config.placeholderConfig == null + ? null + : PlaceholderBuilder(configuration: widget.config.placeholderConfig!); final child = QuillRawEditor( key: _editorKey, controller: controller, config: QuillRawEditorConfig( characterShortcutEvents: widget.config.characterShortcutEvents, spaceShortcutEvents: widget.config.spaceShortcutEvents, + cursorPlaceholderConfig: widget.config.cursorPlaceholderConfig, + placeholderBuilder: placeholderBuilder, onKeyPressed: widget.config.onKeyPressed, customLeadingBuilder: widget.config.customLeadingBlockBuilder, focusNode: widget.focusNode, @@ -1377,6 +1382,7 @@ class RenderEditor extends RenderEditableContainerBox child.getCaretPrototype(child.globalToLocalPosition(textPosition)); _floatingCursorRect = sizeAdjustment.inflateRect(caretPrototype).shift(boundedOffset); + _cursorController .setFloatingCursorTextPosition(_floatingCursorTextPosition); } else { diff --git a/lib/src/editor/raw_editor/builders/placeholder/placeholder_builder.dart b/lib/src/editor/raw_editor/builders/placeholder/placeholder_builder.dart new file mode 100644 index 000000000..cea92efb2 --- /dev/null +++ b/lib/src/editor/raw_editor/builders/placeholder/placeholder_builder.dart @@ -0,0 +1,119 @@ +@internal +library; + +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import '../../../../document/attribute.dart' show Attribute, AttributeScope; +import '../../../../document/nodes/line.dart'; +import 'placeholder_configuration.dart'; + +// The black list of the keys that can not be used or permitted by the builder. +final List _blackList = List.unmodifiable([ + Attribute.align.key, + Attribute.direction.key, + Attribute.lineHeight.key, + Attribute.indent.key, + ...Attribute.inlineKeys, + ...Attribute.ignoreKeys, +]); + +/// A utility class for managing placeholder rendering logic in a document editor. +/// +/// The `PlaceholderBuilder` is responsible for determining when a placeholder +/// should be displayed in an empty node and for constructing the corresponding +/// visual representation. +/// +/// - [configuration]: An instance of [PlaceholderConfig] containing placeholder +/// rendering rules and attribute customizations. +@experimental +@immutable +class PlaceholderBuilder { + const PlaceholderBuilder({ + required this.configuration, + }); + + final PlaceholderConfig configuration; + + Map get builders => + configuration.builders; + Set? get customBlockAttributesKeys => + configuration.customBlockAttributesKeys; + + /// Determines whether a given [Line] node should display a placeholder. + /// + /// This method checks if the node is empty and contains a block-level attribute + /// matching a builder key or custom attribute, excluding keys in the blacklist. + /// + /// Returns a tuple: + /// - [bool]: Whether a placeholder should be shown. + /// - [String]: The key of the matching attribute, if applicable. + @experimental + (bool, String) shouldShowPlaceholder(Line node) { + if (builders.isEmpty) return (false, ''); + var shouldShow = false; + var key = ''; + for (final exclusiveKey in { + ...Attribute.exclusiveBlockKeys, + ...?customBlockAttributesKeys + }) { + if (node.style.containsKey(exclusiveKey) && + node.style.attributes[exclusiveKey]?.scope == AttributeScope.block && + !_blackList.contains(exclusiveKey)) { + shouldShow = true; + key = exclusiveKey; + break; + } + } + // we return if should show placeholders and the key of the attr that matches to get it directly + // avoiding an unnecessary traverse into the attributes of the node + return (node.isEmpty && shouldShow, key); + } + + /// Constructs a [WidgetSpan] for rendering a placeholder in an empty line. + /// + /// This method creates a visual representation of the placeholder based on + /// the block attribute and style configurations provided. Use [shouldShowPlaceholder] + /// before invoking this method to ensure the placeholder is needed. + @experimental + WidgetSpan? build({ + required Attribute blockAttribute, + required TextStyle lineStyle, + required TextAlign align, + TextDirection? textDirection, + StrutStyle? strutStyle, + TextScaler? textScaler, + }) { + if (builders.isEmpty) return null; + final configuration = + builders[blockAttribute.key]?.call(blockAttribute, lineStyle); + // we don't need to add a placeholder that is null or contains a empty text + if (configuration == null || configuration.text.trim().isEmpty) { + return null; + } + final textWidget = Text( + configuration.text, + style: configuration.style, + textDirection: textDirection, + softWrap: true, + strutStyle: strutStyle, + textAlign: align, + textScaler: textScaler, + textWidthBasis: TextWidthBasis.longestLine, + ); + + // Use a [Row] with [Expanded] for placeholders in lines without explicit alignment. + // This ensures the placeholder spans the full width, avoiding unexpected alignment issues. + return WidgetSpan( + style: lineStyle, + child: align == TextAlign.end || align == TextAlign.center + ? textWidget + : Row( + children: [ + Expanded( + child: textWidget, + ), + ], + ), + ); + } +} diff --git a/lib/src/editor/raw_editor/builders/placeholder/placeholder_configuration.dart b/lib/src/editor/raw_editor/builders/placeholder/placeholder_configuration.dart new file mode 100644 index 000000000..a9565b464 --- /dev/null +++ b/lib/src/editor/raw_editor/builders/placeholder/placeholder_configuration.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart' show TextStyle; +import 'package:meta/meta.dart' show experimental, immutable; +import '../../../../document/attribute.dart'; + +typedef PlaceholderComponentBuilder = PlaceholderTextBuilder? Function( + Attribute, TextStyle); + +/// Configuration class for defining how placeholders are handled in the editor. +/// +/// The `PlaceholderConfig` allows customization of placeholder behavior by +/// providing builders for rendering specific components and defining custom +/// attribute keys that should be recognized during the placeholder build process. +/// +/// - [builders]: A map associating placeholder keys with their respective +/// component builders, allowing custom rendering logic. +/// - [customBlockAttributesKeys]: A set of additional attribute keys to include +/// in placeholder processing. By default, only predefined keys are considered. +@experimental +@immutable +class PlaceholderConfig { + const PlaceholderConfig({ + required this.builders, + this.customBlockAttributesKeys, + }); + + factory PlaceholderConfig.base() { + return const PlaceholderConfig(builders: {}); + } + + /// Add custom keys here to include them in placeholder builds, as external keys are ignored by default. + final Set? customBlockAttributesKeys; + final Map builders; + + PlaceholderConfig copyWith({ + Map? builders, + Set? customBlockAttributesKeys, + }) { + return PlaceholderConfig( + builders: builders ?? this.builders, + customBlockAttributesKeys: + customBlockAttributesKeys ?? this.customBlockAttributesKeys, + ); + } +} + +/// Represents the text that will be displayed +@immutable +class PlaceholderTextBuilder { + const PlaceholderTextBuilder({ + required this.text, + required this.style, + }); + + final String text; + final TextStyle style; +} 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 16a5a4549..da4def74c 100644 --- a/lib/src/editor/raw_editor/config/raw_editor_config.dart +++ b/lib/src/editor/raw_editor/config/raw_editor_config.dart @@ -12,7 +12,9 @@ import '../../../editor/widgets/default_styles.dart'; import '../../../editor/widgets/delegate.dart'; import '../../../editor/widgets/link.dart'; import '../../../toolbar/theme/quill_dialog_theme.dart'; +import '../../widgets/cursor_configuration/cursor_configuration.dart'; import '../builders/leading_block_builder.dart'; +import '../builders/placeholder/placeholder_builder.dart'; import 'events/events.dart'; @immutable @@ -28,6 +30,8 @@ class QuillRawEditorConfig { required this.autoFocus, required this.characterShortcutEvents, required this.spaceShortcutEvents, + @experimental this.placeholderBuilder, + @experimental this.cursorPlaceholderConfig, @experimental this.onKeyPressed, this.showCursor = true, this.scrollable = true, @@ -103,6 +107,17 @@ class QuillRawEditorConfig { ///``` final List characterShortcutEvents; + /// Configuration for displaying placeholders in empty lines or near the cursor. + @experimental + final PlaceholderBuilder? placeholderBuilder; + + /// Configures how a placeholder is displayed relative to the cursor. + /// + /// This argument specifies the appearance, style, and position of a placeholder + /// shown at the cursor's location in an empty line. + @experimental + final CursorPlaceholderConfig? cursorPlaceholderConfig; + /// Contains all the events that will be handled when /// space key is pressed /// diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index f026c43fd..ac18859b3 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -592,7 +592,9 @@ class QuillRawEditorState extends EditorState } else if (node is Block) { final editableTextBlock = EditableTextBlock( block: node, + placeholderBuilder: widget.config.placeholderBuilder, controller: controller, + cursorPlaceholderConfig: widget.config.cursorPlaceholderConfig, customLeadingBlockBuilder: widget.config.customLeadingBuilder, textDirection: nodeTextDirection, scrollBottomInset: widget.config.scrollBottomInset, @@ -642,6 +644,7 @@ class QuillRawEditorState extends EditorState final textLine = TextLine( line: node, textDirection: _textDirection, + placeholderBuilder: widget.config.placeholderBuilder, embedBuilder: widget.config.embedBuilder, customStyleBuilder: widget.config.customStyleBuilder, customRecognizerBuilder: widget.config.customRecognizerBuilder, @@ -654,19 +657,21 @@ class QuillRawEditorState extends EditorState composingRange: composingRange.value, ); final editableTextLine = EditableTextLine( - node, - null, - textLine, - _getHorizontalSpacingForLine(node, _styles), - _getVerticalSpacingForLine(node, _styles), - _textDirection, - controller.selection, - widget.config.selectionColor, - widget.config.enableInteractiveSelection, - _hasFocus, - MediaQuery.devicePixelRatioOf(context), - _cursorCont, - _styles!.inlineCode!); + node, + null, + textLine, + _getHorizontalSpacingForLine(node, _styles), + _getVerticalSpacingForLine(node, _styles), + _textDirection, + controller.selection, + widget.config.selectionColor, + widget.config.enableInteractiveSelection, + _hasFocus, + MediaQuery.devicePixelRatioOf(context), + _cursorCont, + _styles!.inlineCode!, + widget.config.cursorPlaceholderConfig, + ); return editableTextLine; } diff --git a/lib/src/editor/widgets/cursor.dart b/lib/src/editor/widgets/cursor.dart index de126e909..20bcb3881 100644 --- a/lib/src/editor/widgets/cursor.dart +++ b/lib/src/editor/widgets/cursor.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import '../../common/utils/platform.dart'; import 'box.dart'; +import 'cursor_configuration/cursor_configuration.dart'; /// Style properties of editing cursor. class CursorStyle { @@ -252,6 +253,7 @@ class CursorPainter { final Rect prototype; final Color color; final double devicePixelRatio; + TextPainter? placeholderPainter; /// Paints cursor on [canvas] at specified [position]. /// [offset] is global top left (x, y) of text line @@ -261,6 +263,9 @@ class CursorPainter { Offset offset, TextPosition position, bool lineHasEmbed, + bool isNodeValid, + CursorPlaceholderConfig? cursorPlaceholderConfig, + TextDirection textDirection, ) { // relative (x, y) to global offset var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype); @@ -325,6 +330,25 @@ class CursorPainter { final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); canvas.drawRRect(caretRRect, paint); } + // we need to make these checks to avoid use this painter unnecessarily + if (cursorPlaceholderConfig != null && + cursorPlaceholderConfig.show && + cursorPlaceholderConfig.text.trim().isNotEmpty) { + if (isNodeValid) { + final localOffset = + cursorPlaceholderConfig.offset ?? const Offset(0, 0); + placeholderPainter ??= TextPainter( + text: TextSpan( + text: cursorPlaceholderConfig.text, + style: cursorPlaceholderConfig.textStyle, + ), + textDirection: textDirection, + ); + placeholderPainter! + ..layout() + ..paint(canvas, offset + localOffset); + } + } } Offset _getPixelPerfectCursorOffset( diff --git a/lib/src/editor/widgets/cursor_configuration/cursor_configuration.dart b/lib/src/editor/widgets/cursor_configuration/cursor_configuration.dart new file mode 100644 index 000000000..b1a0cd649 --- /dev/null +++ b/lib/src/editor/widgets/cursor_configuration/cursor_configuration.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +const TextStyle _defaultPlaceholderStyle = + TextStyle(color: Colors.grey, fontStyle: FontStyle.italic); + +/// Configuration for displaying a placeholder near the cursor in a rich text editor. +/// +/// The `CursorPlaceholderConfig` defines the appearance, position, and behavior +/// of a placeholder that is shown when a line is empty and the cursor is present. +/// This feature mimics behavior in some rich text editors where placeholder text +/// (e.g., "Start writing...") appears as a prompt when no content is entered. +@immutable +class CursorPlaceholderConfig { + const CursorPlaceholderConfig({ + required this.text, + required this.textStyle, + required this.show, + required this.offset, + }); + + /// Creates a basic configuration for a cursor placeholder with default text and style. + /// + /// Parameters: + /// - [textStyle]: An optional custom style for the placeholder text. + /// Defaults to [_defaultPlaceholderStyle] if not provided. + factory CursorPlaceholderConfig.basic({TextStyle? textStyle}) { + return CursorPlaceholderConfig( + text: 'Enter text...', + textStyle: textStyle ?? _defaultPlaceholderStyle, + show: true, + offset: const Offset(3.5, 5), + ); + } + + factory CursorPlaceholderConfig.noPlaceholder() { + return const CursorPlaceholderConfig( + text: '', + textStyle: TextStyle(), + show: false, + offset: null, + ); + } + + /// this text that will be showed at the right + /// or left of the cursor + final String text; + + /// this is the text style of the placeholder + final TextStyle textStyle; + + /// Decides if the placeholder should be showed + final bool show; + + /// The offset position where the placeholder text will be rendered. + final Offset? offset; + + @override + int get hashCode => + text.hashCode ^ textStyle.hashCode ^ show.hashCode ^ offset.hashCode; + + @override + bool operator ==(covariant CursorPlaceholderConfig other) { + if (identical(this, other)) return true; + return other.show == show && + other.text == text && + other.textStyle == textStyle && + other.offset == offset; + } +} diff --git a/lib/src/editor/widgets/text/text_block.dart b/lib/src/editor/widgets/text/text_block.dart index 09d170823..2ad04eb60 100644 --- a/lib/src/editor/widgets/text/text_block.dart +++ b/lib/src/editor/widgets/text/text_block.dart @@ -13,8 +13,10 @@ import '../../../editor_toolbar_shared/color.dart'; import '../../editor.dart'; import '../../embed/embed_editor_builder.dart'; import '../../raw_editor/builders/leading_block_builder.dart'; +import '../../raw_editor/builders/placeholder/placeholder_builder.dart'; import '../box.dart'; import '../cursor.dart'; +import '../cursor_configuration/cursor_configuration.dart'; import '../default_leading_components/leading_components.dart'; import '../default_styles.dart'; import '../delegate.dart'; @@ -77,6 +79,8 @@ class EditableTextBlock extends StatelessWidget { required this.onCheckboxTap, required this.readOnly, required this.customRecognizerBuilder, + required this.placeholderBuilder, + required this.cursorPlaceholderConfig, required this.composingRange, this.checkBoxReadOnly, this.onLaunchUrl, @@ -92,6 +96,8 @@ class EditableTextBlock extends StatelessWidget { final double scrollBottomInset; final HorizontalSpacing horizontalSpacing; final VerticalSpacing verticalSpacing; + final PlaceholderBuilder? placeholderBuilder; + final CursorPlaceholderConfig? cursorPlaceholderConfig; final TextSelection textSelection; final Color color; final DefaultStyles? styles; @@ -186,6 +192,7 @@ class EditableTextBlock extends StatelessWidget { line: line, textDirection: textDirection, embedBuilder: embedBuilder, + placeholderBuilder: placeholderBuilder, customStyleBuilder: customStyleBuilder, styles: styles!, readOnly: readOnly, @@ -206,6 +213,7 @@ class EditableTextBlock extends StatelessWidget { MediaQuery.devicePixelRatioOf(context), cursorCont, styles!.inlineCode!, + cursorPlaceholderConfig, ); final nodeTextDirection = getDirectionOfNode(line, textDirection); children.add( diff --git a/lib/src/editor/widgets/text/text_line.dart b/lib/src/editor/widgets/text/text_line.dart index fc33ae58f..5594e0c90 100644 --- a/lib/src/editor/widgets/text/text_line.dart +++ b/lib/src/editor/widgets/text/text_line.dart @@ -9,11 +9,13 @@ import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../flutter_quill.dart'; +import '../../../common/extensions/nodes_ext.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 '../../raw_editor/builders/placeholder/placeholder_builder.dart'; import '../box.dart'; import '../delegate.dart'; import '../keyboard_listener.dart'; @@ -30,6 +32,7 @@ class TextLine extends StatefulWidget { required this.onLaunchUrl, required this.linkActionPicker, required this.composingRange, + this.placeholderBuilder, this.textDirection, this.customStyleBuilder, this.customRecognizerBuilder, @@ -39,6 +42,7 @@ class TextLine extends StatefulWidget { final Line line; final TextDirection? textDirection; + final PlaceholderBuilder? placeholderBuilder; final EmbedsBuilder embedBuilder; final DefaultStyles styles; final bool readOnly; @@ -268,7 +272,33 @@ class _TextLineState extends State { LinkedList nodes, TextStyle lineStyle, ) { - if (nodes.isEmpty && kIsWeb) { + var addWebNodeIfNeeded = widget.placeholderBuilder == null; + if (widget.placeholderBuilder != null && + nodes.isEmpty && + widget.placeholderBuilder!.builders.isNotEmpty) { + final (shouldShowNode, attrKey) = + widget.placeholderBuilder!.shouldShowPlaceholder(widget.line); + if (shouldShowNode) { + final style = lineStyle.merge(_getInlineTextStyle( + const Style(), defaultStyles, widget.line.style, false)); + final placeholderWidget = widget.placeholderBuilder!.build( + blockAttribute: widget.line.style.attributes[attrKey]!, + lineStyle: style, + textDirection: widget.textDirection, + align: _getTextAlign(), + textScaler: MediaQuery.textScalerOf(context), + strutStyle: StrutStyle.fromTextStyle(style), + ); + if (placeholderWidget != null) { + return TextSpan(children: [placeholderWidget], style: style); + } + } + // if the [placeholderWidget] is null or [shouldShowNode] is false + // then this line will be executed and avoid non add + // the needed node when the line is empty + addWebNodeIfNeeded = true; + } + if (nodes.isEmpty && kIsWeb && addWebNodeIfNeeded) { nodes = LinkedList()..add(leaf.QuillText('\u{200B}')); } @@ -686,6 +716,7 @@ class EditableTextLine extends RenderObjectWidget { this.devicePixelRatio, this.cursorCont, this.inlineCodeStyle, + this.cursorPlaceholderConfig, {super.key}); final Line line; @@ -693,6 +724,7 @@ class EditableTextLine extends RenderObjectWidget { final Widget body; final HorizontalSpacing horizontalSpacing; final VerticalSpacing verticalSpacing; + final CursorPlaceholderConfig? cursorPlaceholderConfig; final TextDirection textDirection; final TextSelection textSelection; final Color color; @@ -710,16 +742,18 @@ class EditableTextLine extends RenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { return RenderEditableTextLine( - line, - textDirection, - textSelection, - enableInteractiveSelection, - hasFocus, - devicePixelRatio, - _getPadding(), - color, - cursorCont, - inlineCodeStyle); + line, + textDirection, + textSelection, + enableInteractiveSelection, + hasFocus, + devicePixelRatio, + _getPadding(), + color, + cursorCont, + inlineCodeStyle, + cursorPlaceholderConfig, + ); } @override @@ -731,6 +765,7 @@ class EditableTextLine extends RenderObjectWidget { ..setTextDirection(textDirection) ..setTextSelection(textSelection) ..setColor(color) + ..setCursorParagraphPlaceholderConfiguration(cursorPlaceholderConfig) ..setEnableInteractiveSelection(enableInteractiveSelection) ..hasFocus = hasFocus ..setDevicePixelRatio(devicePixelRatio) @@ -762,6 +797,7 @@ class RenderEditableTextLine extends RenderEditableBox { this.color, this.cursorCont, this.inlineCodeStyle, + this.cursorPlaceholderConfig, ); RenderBox? _leading; @@ -771,6 +807,7 @@ class RenderEditableTextLine extends RenderEditableBox { TextSelection textSelection; Color color; bool enableInteractiveSelection; + CursorPlaceholderConfig? cursorPlaceholderConfig; bool hasFocus = false; double devicePixelRatio; EdgeInsetsGeometry padding; @@ -799,6 +836,14 @@ class RenderEditableTextLine extends RenderEditableBox { markNeedsLayout(); } + void setCursorParagraphPlaceholderConfiguration(CursorPlaceholderConfig? c) { + if (cursorPlaceholderConfig == c) { + return; + } + cursorPlaceholderConfig = c; + markNeedsLayout(); + } + void setDevicePixelRatio(double d) { if (devicePixelRatio == d) { return; @@ -1396,11 +1441,15 @@ class RenderEditableTextLine extends RenderEditableBox { offset: textSelection.extentOffset - line.documentOffset, affinity: textSelection.base.affinity, ); + final isNodeValid = line.isNodeInline() && line.isEmpty; _cursorPainter.paint( context.canvas, effectiveOffset, position, lineHasEmbed, + isNodeValid, + cursorPlaceholderConfig, + textDirection, ); }