diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index 2ec7d7f2..9bf75396 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -42,6 +42,8 @@ class Attribute { Attribute.style.key: Attribute.style, Attribute.token.key: Attribute.token, Attribute.script.key: Attribute.script, + Attribute.image.key: Attribute.image, + Attribute.video.key: Attribute.video, }); static const BoldAttribute bold = BoldAttribute(); @@ -90,7 +92,7 @@ class Attribute { static const TokenAttribute token = TokenAttribute(''); - static const ScriptAttribute script = ScriptAttribute(''); + static final ScriptAttribute script = ScriptAttribute(null); static const String mobileWidth = 'mobileWidth'; @@ -100,6 +102,10 @@ class Attribute { static const String mobileAlignment = 'mobileAlignment'; + static const ImageAttribute image = ImageAttribute(null); + + static const VideoAttribute video = VideoAttribute(null); + static final Set inlineKeys = { Attribute.bold.key, Attribute.italic.key, @@ -138,6 +144,11 @@ class Attribute { Attribute.blockQuote.key, }); + static final Set embedKeys = { + Attribute.image.key, + Attribute.video.key, + }; + static const Attribute h1 = HeaderAttribute(level: 1); static const Attribute h2 = HeaderAttribute(level: 2); @@ -346,7 +357,26 @@ class TokenAttribute extends Attribute { } // `script` is supposed to be inline attribute but it is not supported yet -class ScriptAttribute extends Attribute { - const ScriptAttribute(String val) - : super('script', AttributeScope.IGNORE, val); +class ScriptAttribute extends Attribute { + ScriptAttribute(ScriptAttributes? val) + : super('script', AttributeScope.IGNORE, val?.value); +} + +enum ScriptAttributes { + sup('super'), + sub('sup'); + + const ScriptAttributes(this.value); + + final String value; +} + +class ImageAttribute extends Attribute { + const ImageAttribute(String? url) + : super('image', AttributeScope.EMBEDS, url); +} + +class VideoAttribute extends Attribute { + const VideoAttribute(String? url) + : super('video', AttributeScope.EMBEDS, url); } diff --git a/lib/src/models/themes/quill_dialog_theme.dart b/lib/src/models/themes/quill_dialog_theme.dart index 795d35d5..47d732b9 100644 --- a/lib/src/models/themes/quill_dialog_theme.dart +++ b/lib/src/models/themes/quill_dialog_theme.dart @@ -1,8 +1,19 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class QuillDialogTheme { - QuillDialogTheme( - {this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor}); +/// Used to configure the dialog's look and feel. +class QuillDialogTheme with Diagnosticable { + const QuillDialogTheme({ + this.labelTextStyle, + this.inputTextStyle, + this.dialogBackgroundColor, + this.shape, + this.buttonStyle, + this.linkDialogConstraints, + this.imageDialogConstraints, + this.isWrappable = false, + this.runSpacing = 8.0, + }) : assert(runSpacing >= 0); ///The text style to use for the label shown in the link-input dialog final TextStyle? labelTextStyle; @@ -10,6 +21,89 @@ class QuillDialogTheme { ///The text style to use for the input text shown in the link-input dialog final TextStyle? inputTextStyle; - ///The background color for the [LinkDialog()] + ///The background color for the Quill dialog final Color? dialogBackgroundColor; + + /// The shape of this dialog's border. + /// + /// Defines the dialog's [Material.shape]. + /// + /// The default shape is a [RoundedRectangleBorder] with a radius of 4.0 + final ShapeBorder? shape; + + /// Constrains for [LinkStyleDialog]. + final BoxConstraints? linkDialogConstraints; + + /// Constrains for [EmbedImageDialog]. + final BoxConstraints? imageDialogConstraints; + + /// Customizes this button's appearance. + final ButtonStyle? buttonStyle; + + /// Whether dialog's children are wrappred with [Wrap] instead of [Row]. + final bool isWrappable; + + /// How much space to place between the runs themselves in the cross axis. + /// + /// Make sense if [isWrappable] is `true`. + /// + /// Defaults to 0.0. + final double runSpacing; + + QuillDialogTheme copyWith({ + TextStyle? labelTextStyle, + TextStyle? inputTextStyle, + Color? dialogBackgroundColor, + ShapeBorder? shape, + ButtonStyle? buttonStyle, + BoxConstraints? linkDialogConstraints, + BoxConstraints? imageDialogConstraints, + bool? isWrappable, + double? runSpacing, + }) { + return QuillDialogTheme( + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + inputTextStyle: inputTextStyle ?? this.inputTextStyle, + dialogBackgroundColor: + dialogBackgroundColor ?? this.dialogBackgroundColor, + shape: shape ?? this.shape, + buttonStyle: buttonStyle ?? this.buttonStyle, + linkDialogConstraints: + linkDialogConstraints ?? this.linkDialogConstraints, + imageDialogConstraints: + imageDialogConstraints ?? this.imageDialogConstraints, + isWrappable: isWrappable ?? this.isWrappable, + runSpacing: runSpacing ?? this.runSpacing, + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is QuillDialogTheme && + other.labelTextStyle == labelTextStyle && + other.inputTextStyle == inputTextStyle && + other.dialogBackgroundColor == dialogBackgroundColor && + other.shape == shape && + other.buttonStyle == buttonStyle && + other.linkDialogConstraints == linkDialogConstraints && + other.imageDialogConstraints == imageDialogConstraints && + other.isWrappable == isWrappable && + other.runSpacing == runSpacing; + } + + @override + int get hashCode => Object.hash( + labelTextStyle, + inputTextStyle, + dialogBackgroundColor, + shape, + buttonStyle, + linkDialogConstraints, + imageDialogConstraints, + isWrappable, + runSpacing, + ); } diff --git a/lib/src/translations/toolbar.i18n.dart b/lib/src/translations/toolbar.i18n.dart index cb6c7853..507d6ebd 100644 --- a/lib/src/translations/toolbar.i18n.dart +++ b/lib/src/translations/toolbar.i18n.dart @@ -61,6 +61,10 @@ extension Localization on String { 'Increase indent': 'Increase indent', 'Decrease indent': 'Decrease indent', 'Insert URL': 'Insert URL', + 'Visit link': 'Visit link', + 'Enter link': 'Enter link', + 'Edit': 'Edit', + 'Apply': 'Apply', }, 'en_us': { 'Paste a link': 'Paste a link', @@ -120,6 +124,10 @@ extension Localization on String { 'Increase indent': 'Increase indent', 'Decrease indent': 'Decrease indent', 'Insert URL': 'Insert URL', + 'Visit link': 'Visit link', + 'Enter link': 'Enter link', + 'Edit': 'Edit', + 'Apply': 'Apply', }, 'ar': { 'Paste a link': 'نسخ الرابط', diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 2a1bc5e3..1c360ead 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -15,6 +15,7 @@ import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/leaf.dart'; import '../models/documents/style.dart'; import '../models/structs/offset_value.dart'; +import '../models/themes/quill_dialog_theme.dart'; import '../utils/platform.dart'; import 'box.dart'; import 'controller.dart'; @@ -143,49 +144,50 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics { } class QuillEditor extends StatefulWidget { - const QuillEditor( - {required this.controller, - required this.focusNode, - required this.scrollController, - required this.scrollable, - required this.padding, - required this.autoFocus, - required this.readOnly, - required this.expands, - this.showCursor, - this.paintCursorAboveText, - this.placeholder, - this.enableInteractiveSelection = true, - this.enableSelectionToolbar = true, - this.scrollBottomInset = 0, - this.minHeight, - this.maxHeight, - this.maxContentWidth, - this.customStyles, - this.textCapitalization = TextCapitalization.sentences, - this.keyboardAppearance = Brightness.light, - this.scrollPhysics, - this.onLaunchUrl, - this.onTapDown, - this.onTapUp, - this.onSingleLongTapStart, - this.onSingleLongTapMoveUpdate, - this.onSingleLongTapEnd, - this.embedBuilders, - this.unknownEmbedBuilder, - this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, - this.customStyleBuilder, - this.locale, - this.floatingCursorDisabled = false, - this.textSelectionControls, - this.onImagePaste, - this.customShortcuts, - this.customActions, - this.detectWordBoundary = true, - this.enableUnfocusOnTapOutside = true, - this.customLinkPrefixes = const [], - Key? key}) - : super(key: key); + const QuillEditor({ + required this.controller, + required this.focusNode, + required this.scrollController, + required this.scrollable, + required this.padding, + required this.autoFocus, + required this.readOnly, + required this.expands, + this.showCursor, + this.paintCursorAboveText, + this.placeholder, + this.enableInteractiveSelection = true, + this.enableSelectionToolbar = true, + this.scrollBottomInset = 0, + this.minHeight, + this.maxHeight, + this.maxContentWidth, + this.customStyles, + this.textCapitalization = TextCapitalization.sentences, + this.keyboardAppearance = Brightness.light, + this.scrollPhysics, + this.onLaunchUrl, + this.onTapDown, + this.onTapUp, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.embedBuilders, + this.unknownEmbedBuilder, + this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, + this.customStyleBuilder, + this.locale, + this.floatingCursorDisabled = false, + this.textSelectionControls, + this.onImagePaste, + this.customShortcuts, + this.customActions, + this.detectWordBoundary = true, + this.enableUnfocusOnTapOutside = true, + this.customLinkPrefixes = const [], + this.dialogTheme, + Key? key, + }) : super(key: key); factory QuillEditor.basic({ required QuillController controller, @@ -302,6 +304,7 @@ class QuillEditor extends StatefulWidget { /// horizontally centered. This is mostly useful on devices with wide screens. final double? maxContentWidth; + /// Allows to override [DefaultStyles]. final DefaultStyles? customStyles; /// Whether this editor's height will be sized to fill its parent. @@ -401,7 +404,14 @@ class QuillEditor extends StatefulWidget { /// Returns the url of the image if the image should be inserted. final Future Function(Uint8List imageBytes)? onImagePaste; - final Map? customShortcuts; + /// Contains user-defined shortcuts map. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts] + final Map? customShortcuts; + + /// Contains user-defined actions. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions] final Map>? customActions; final bool detectWordBoundary; @@ -412,6 +422,9 @@ class QuillEditor extends StatefulWidget { /// Useful for deeplinks final List customLinkPrefixes; + /// Configures the dialog theme. + final QuillDialogTheme? dialogTheme; + @override QuillEditorState createState() => QuillEditorState(); } @@ -511,6 +524,7 @@ class QuillEditorState extends State customActions: widget.customActions, customLinkPrefixes: widget.customLinkPrefixes, enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside, + dialogTheme: widget.dialogTheme, ); final editor = I18n( diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 64059983..b8218f8b 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -2,10 +2,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; -// ignore: unnecessary_import -import 'dart:typed_data'; import 'dart:ui' as ui hide TextStyle; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -24,6 +23,7 @@ import '../models/documents/nodes/node.dart'; import '../models/documents/style.dart'; import '../models/structs/offset_value.dart'; import '../models/structs/vertical_spacing.dart'; +import '../models/themes/quill_dialog_theme.dart'; import '../utils/cast.dart'; import '../utils/delta.dart'; import '../utils/embeds.dart'; @@ -42,46 +42,48 @@ import 'raw_editor/raw_editor_state_text_input_client_mixin.dart'; import 'text_block.dart'; import 'text_line.dart'; import 'text_selection.dart'; +import 'toolbar/link_style_button2.dart'; import 'toolbar/search_dialog.dart'; class RawEditor extends StatefulWidget { - const RawEditor( - {required this.controller, - required this.focusNode, - required this.scrollController, - required this.scrollBottomInset, - required this.cursorStyle, - required this.selectionColor, - required this.selectionCtrls, - required this.embedBuilder, - Key? key, - this.scrollable = true, - this.padding = EdgeInsets.zero, - this.readOnly = false, - this.placeholder, - this.onLaunchUrl, - this.contextMenuBuilder = defaultContextMenuBuilder, - this.showSelectionHandles = false, - bool? showCursor, - this.textCapitalization = TextCapitalization.none, - this.maxHeight, - this.minHeight, - this.maxContentWidth, - this.customStyles, - this.customShortcuts, - this.customActions, - this.expands = false, - this.autoFocus = false, - this.enableUnfocusOnTapOutside = true, - this.keyboardAppearance = Brightness.light, - this.enableInteractiveSelection = true, - this.scrollPhysics, - this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, - this.customStyleBuilder, - this.floatingCursorDisabled = false, - this.onImagePaste, - this.customLinkPrefixes = const []}) - : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), + const RawEditor({ + required this.controller, + required this.focusNode, + required this.scrollController, + required this.scrollBottomInset, + required this.cursorStyle, + required this.selectionColor, + required this.selectionCtrls, + required this.embedBuilder, + Key? key, + this.scrollable = true, + this.padding = EdgeInsets.zero, + this.readOnly = false, + this.placeholder, + this.onLaunchUrl, + this.contextMenuBuilder = defaultContextMenuBuilder, + this.showSelectionHandles = false, + bool? showCursor, + this.textCapitalization = TextCapitalization.none, + this.maxHeight, + this.minHeight, + this.maxContentWidth, + this.customStyles, + this.customShortcuts, + this.customActions, + this.expands = false, + this.autoFocus = false, + this.enableUnfocusOnTapOutside = true, + this.keyboardAppearance = Brightness.light, + this.enableInteractiveSelection = true, + this.scrollPhysics, + this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, + this.customStyleBuilder, + this.floatingCursorDisabled = false, + this.onImagePaste, + this.customLinkPrefixes = const [], + this.dialogTheme, + }) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, 'maxHeight cannot be null'), @@ -190,6 +192,7 @@ class RawEditor extends StatefulWidget { /// horizontally centered. This is mostly useful on devices with wide screens. final double? maxContentWidth; + /// Allows to override [DefaultStyles]. final DefaultStyles? customStyles; /// Whether this widget's height will be sized to fill its parent. @@ -245,7 +248,14 @@ class RawEditor extends StatefulWidget { final Future Function(Uint8List imageBytes)? onImagePaste; - final Map? customShortcuts; + /// Contains user-defined shortcuts map. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts] + final Map? customShortcuts; + + /// Contains user-defined actions. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions] final Map>? customActions; /// Builder function for embeddable objects. @@ -255,6 +265,9 @@ class RawEditor extends StatefulWidget { final bool floatingCursorDisabled; final List customLinkPrefixes; + /// Configures the dialog theme. + final QuillDialogTheme? dialogTheme; + @override State createState() => RawEditorState(); } @@ -495,78 +508,148 @@ class RawEditorState extends EditorState minHeight: widget.minHeight ?? 0.0, maxHeight: widget.maxHeight ?? double.infinity); + final isMacOS = Theme.of(context).platform == TargetPlatform.macOS; + return TextFieldTapRegion( enabled: widget.enableUnfocusOnTapOutside, onTapOutside: _defaultOnTapOutside, child: QuillStyles( data: _styles!, child: Shortcuts( - shortcuts: { + shortcuts: mergeMaps({ // shortcuts added for Desktop platforms. - LogicalKeySet(LogicalKeyboardKey.escape): - const HideSelectionToolbarIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): - const UndoTextIntent(SelectionChangedCause.keyboard), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyY): - const RedoTextIntent(SelectionChangedCause.keyboard), + const SingleActivator( + LogicalKeyboardKey.escape, + ): const HideSelectionToolbarIntent(), + SingleActivator( + LogicalKeyboardKey.keyZ, + control: !isMacOS, + meta: isMacOS, + ): const UndoTextIntent(SelectionChangedCause.keyboard), + SingleActivator( + LogicalKeyboardKey.keyY, + control: !isMacOS, + meta: isMacOS, + ): const RedoTextIntent(SelectionChangedCause.keyboard), // Selection formatting. - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB): - const ToggleTextStyleIntent(Attribute.bold), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyU): - const ToggleTextStyleIntent(Attribute.underline), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI): - const ToggleTextStyleIntent(Attribute.italic), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - LogicalKeyboardKey.keyS): - const ToggleTextStyleIntent(Attribute.strikeThrough), - LogicalKeySet( - LogicalKeyboardKey.control, LogicalKeyboardKey.backquote): - const ToggleTextStyleIntent(Attribute.inlineCode), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL): - const ToggleTextStyleIntent(Attribute.ul), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyO): - const ToggleTextStyleIntent(Attribute.ol), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - LogicalKeyboardKey.keyB): - const ToggleTextStyleIntent(Attribute.blockQuote), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - LogicalKeyboardKey.tilde): - const ToggleTextStyleIntent(Attribute.codeBlock), - // Indent - LogicalKeySet(LogicalKeyboardKey.control, - LogicalKeyboardKey.bracketRight): - const IndentSelectionIntent(true), - LogicalKeySet( - LogicalKeyboardKey.control, LogicalKeyboardKey.bracketLeft): - const IndentSelectionIntent(false), - - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): - const OpenSearchIntent(), - - LogicalKeySet( - LogicalKeyboardKey.control, LogicalKeyboardKey.digit1): - const ApplyHeaderIntent(Attribute.h1), - LogicalKeySet( - LogicalKeyboardKey.control, LogicalKeyboardKey.digit2): - const ApplyHeaderIntent(Attribute.h2), - LogicalKeySet( - LogicalKeyboardKey.control, LogicalKeyboardKey.digit3): - const ApplyHeaderIntent(Attribute.h3), - LogicalKeySet( - LogicalKeyboardKey.control, LogicalKeyboardKey.digit0): - const ApplyHeaderIntent(Attribute.header), - - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - LogicalKeyboardKey.keyL): const ApplyCheckListIntent(), - - if (widget.customShortcuts != null) ...widget.customShortcuts!, - }, + SingleActivator( + LogicalKeyboardKey.keyB, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.bold), + SingleActivator( + LogicalKeyboardKey.keyU, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.underline), + SingleActivator( + LogicalKeyboardKey.keyI, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.italic), + SingleActivator( + LogicalKeyboardKey.keyS, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.strikeThrough), + SingleActivator( + LogicalKeyboardKey.backquote, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.inlineCode), + SingleActivator( + LogicalKeyboardKey.tilde, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.codeBlock), + SingleActivator( + LogicalKeyboardKey.keyB, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.blockQuote), + SingleActivator( + LogicalKeyboardKey.keyK, + control: !isMacOS, + meta: isMacOS, + ): const ApplyLinkIntent(), + + // Lists + SingleActivator( + LogicalKeyboardKey.keyL, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.ul), + SingleActivator( + LogicalKeyboardKey.keyO, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.ol), + SingleActivator( + LogicalKeyboardKey.keyC, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ApplyCheckListIntent(), + + // Indents + SingleActivator( + LogicalKeyboardKey.keyM, + control: !isMacOS, + meta: isMacOS, + ): const IndentSelectionIntent(true), + SingleActivator( + LogicalKeyboardKey.keyM, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const IndentSelectionIntent(false), + + // Headers + SingleActivator( + LogicalKeyboardKey.digit1, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.h1), + SingleActivator( + LogicalKeyboardKey.digit2, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.h2), + SingleActivator( + LogicalKeyboardKey.digit3, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.h3), + SingleActivator( + LogicalKeyboardKey.digit0, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.header), + + SingleActivator( + LogicalKeyboardKey.keyG, + control: !isMacOS, + meta: isMacOS, + ): const InsertEmbedIntent(Attribute.image), + + SingleActivator( + LogicalKeyboardKey.keyF, + control: !isMacOS, + meta: isMacOS, + ): const OpenSearchIntent(), + }, { + ...?widget.customShortcuts + }), child: Actions( - actions: { - ..._actions, - if (widget.customActions != null) ...widget.customActions!, - }, + actions: mergeMaps>(_actions, { + ...?widget.customActions, + }), child: Focus( focusNode: widget.focusNode, onKey: _onKey, @@ -1570,11 +1653,13 @@ class RawEditorState extends EditorState RedoTextIntent: _makeOverridable(_RedoKeyboardAction(this)), OpenSearchIntent: _openSearchAction, + // Selection Formatting ToggleTextStyleIntent: _formatSelectionAction, IndentSelectionIntent: _indentSelectionAction, ApplyHeaderIntent: _applyHeaderAction, ApplyCheckListIntent: _applyCheckListAction, + ApplyLinkIntent: ApplyLinkAction(this) }; @override @@ -2490,6 +2575,43 @@ class _ApplyCheckListAction extends Action { bool get isActionEnabled => true; } +class ApplyLinkIntent extends Intent { + const ApplyLinkIntent(); +} + +class ApplyLinkAction extends Action { + ApplyLinkAction(this.state); + + final RawEditorState state; + + @override + Object? invoke(ApplyLinkIntent intent) async { + final initialTextLink = QuillTextLink.prepare(state.controller); + + final textLink = await showDialog( + context: state.context, + builder: (context) { + return LinkStyleDialog( + text: initialTextLink.text, + link: initialTextLink.link, + dialogTheme: state.widget.dialogTheme, + ); + }, + ); + + if (textLink != null) { + textLink.submit(state.controller); + } + return null; + } +} + +class InsertEmbedIntent extends Intent { + const InsertEmbedIntent(this.type); + + final Attribute type; +} + /// Signature for a widget builder that builds a context menu for the given /// [RawEditorState]. /// diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index d5a5c93a..f28eaee9 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -29,6 +29,7 @@ export 'toolbar/color_button.dart'; export 'toolbar/history_button.dart'; export 'toolbar/indent_button.dart'; export 'toolbar/link_style_button.dart'; +export 'toolbar/link_style_button2.dart'; export 'toolbar/quill_font_family_button.dart'; export 'toolbar/quill_font_size_button.dart'; export 'toolbar/quill_icon_button.dart'; diff --git a/lib/src/widgets/toolbar/link_style_button2.dart b/lib/src/widgets/toolbar/link_style_button2.dart new file mode 100644 index 00000000..ad73152a --- /dev/null +++ b/lib/src/widgets/toolbar/link_style_button2.dart @@ -0,0 +1,447 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/link.dart'; + +import '../../../extensions.dart'; +import '../../../translations.dart'; +import '../../models/documents/attribute.dart'; +import '../../models/themes/quill_dialog_theme.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../../utils/widgets.dart'; +import '../controller.dart'; +import '../link.dart'; +import '../toolbar.dart'; + +/// Alternative version of [LinkStyleButton]. This widget has more customization +/// and uses dialog similar to one which is used on [http://quilljs.com]. +class LinkStyleButton2 extends StatefulWidget { + const LinkStyleButton2({ + required this.controller, + this.icon, + this.iconSize = kDefaultIconSize, + this.iconTheme, + this.dialogTheme, + this.afterButtonPressed, + this.tooltip, + this.constraints, + this.addLinkLabel, + this.editLinkLabel, + this.linkColor, + this.childrenSpacing = 16.0, + this.autovalidateMode = AutovalidateMode.disabled, + this.validationMessage, + this.buttonSize, + Key? key, + }) : assert(addLinkLabel == null || addLinkLabel.length > 0), + assert(editLinkLabel == null || editLinkLabel.length > 0), + assert(childrenSpacing > 0), + assert(validationMessage == null || validationMessage.length > 0), + super(key: key); + + final QuillController controller; + final IconData? icon; + final double iconSize; + final QuillIconTheme? iconTheme; + final QuillDialogTheme? dialogTheme; + final VoidCallback? afterButtonPressed; + final String? tooltip; + + /// The constrains for dialog. + final BoxConstraints? constraints; + + /// The text of label in link add mode. + final String? addLinkLabel; + + /// The text of label in link edit mode. + final String? editLinkLabel; + + /// The color of URL. + final Color? linkColor; + + /// The margin between child widgets in the dialog. + final double childrenSpacing; + + final AutovalidateMode autovalidateMode; + final String? validationMessage; + + /// The size of dialog buttons. + final Size? buttonSize; + + @override + State createState() => _LinkStyleButton2State(); +} + +class _LinkStyleButton2State extends State { + @override + void dispose() { + super.dispose(); + widget.controller.removeListener(_didChangeSelection); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_didChangeSelection); + } + + @override + void didUpdateWidget(covariant LinkStyleButton2 oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeSelection); + widget.controller.addListener(_didChangeSelection); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isToggled = _getLinkAttributeValue() != null; + return QuillIconButton( + tooltip: widget.tooltip, + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * kIconButtonFactor, + icon: Icon( + widget.icon ?? Icons.link, + size: widget.iconSize, + color: isToggled + ? (widget.iconTheme?.iconSelectedColor ?? + theme.primaryIconTheme.color) + : (widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color), + ), + fillColor: isToggled + ? (widget.iconTheme?.iconSelectedFillColor ?? + Theme.of(context).primaryColor) + : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor), + borderRadius: widget.iconTheme?.borderRadius ?? 2, + onPressed: _openLinkDialog, + afterPressed: widget.afterButtonPressed, + ); + } + + Future _openLinkDialog() async { + final initialTextLink = QuillTextLink.prepare(widget.controller); + + final textLink = await showDialog( + context: context, + builder: (_) => LinkStyleDialog( + dialogTheme: widget.dialogTheme, + text: initialTextLink.text, + link: initialTextLink.link, + constraints: widget.constraints, + addLinkLabel: widget.addLinkLabel, + editLinkLabel: widget.editLinkLabel, + linkColor: widget.linkColor, + childrenSpacing: widget.childrenSpacing, + autovalidateMode: widget.autovalidateMode, + validationMessage: widget.validationMessage, + buttonSize: widget.buttonSize, + ), + ); + + if (textLink != null) { + textLink.submit(widget.controller); + } + } + + String? _getLinkAttributeValue() { + return widget.controller + .getSelectionStyle() + .attributes[Attribute.link.key] + ?.value; + } + + void _didChangeSelection() { + setState(() {}); + } +} + +class LinkStyleDialog extends StatefulWidget { + const LinkStyleDialog({ + Key? key, + this.text, + this.link, + this.dialogTheme, + this.constraints, + this.contentPadding = + const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + this.addLinkLabel, + this.editLinkLabel, + this.linkColor, + this.childrenSpacing = 16.0, + this.autovalidateMode = AutovalidateMode.disabled, + this.validationMessage, + this.buttonSize, + }) : assert(addLinkLabel == null || addLinkLabel.length > 0), + assert(editLinkLabel == null || editLinkLabel.length > 0), + assert(childrenSpacing > 0), + assert(validationMessage == null || validationMessage.length > 0), + super(key: key); + + final String? text; + final String? link; + final QuillDialogTheme? dialogTheme; + + /// The constrains for dialog. + final BoxConstraints? constraints; + + /// The padding for content of dialog. + final EdgeInsetsGeometry contentPadding; + + /// The text of label in link add mode. + final String? addLinkLabel; + + /// The text of label in link edit mode. + final String? editLinkLabel; + + /// The color of URL. + final Color? linkColor; + + /// The margin between child widgets in the dialog. + final double childrenSpacing; + + final AutovalidateMode autovalidateMode; + final String? validationMessage; + + /// The size of dialog buttons. + final Size? buttonSize; + + @override + State createState() => _LinkStyleDialogState(); +} + +class _LinkStyleDialogState extends State { + late final TextEditingController _linkController; + + late String _link; + late String _text; + + late bool _isEditMode; + + @override + void dispose() { + _linkController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _link = widget.link ?? ''; + _text = widget.text ?? ''; + _isEditMode = _link.isNotEmpty; + _linkController = TextEditingController.fromValue( + TextEditingValue( + text: _isEditMode ? _link : '', + selection: _isEditMode + ? TextSelection(baseOffset: 0, extentOffset: _link.length) + : const TextSelection.collapsed(offset: 0), + ), + ); + } + + @override + Widget build(BuildContext context) { + final constraints = widget.constraints ?? + widget.dialogTheme?.linkDialogConstraints ?? + () { + final mediaQuery = MediaQuery.of(context); + final maxWidth = + kIsWeb ? mediaQuery.size.width / 4 : mediaQuery.size.width - 80; + return BoxConstraints(maxWidth: maxWidth, maxHeight: 80); + }(); + + final buttonStyle = widget.buttonSize != null + ? Theme.of(context) + .elevatedButtonTheme + .style + ?.copyWith(fixedSize: MaterialStatePropertyAll(widget.buttonSize)) + : widget.dialogTheme?.buttonStyle; + + final isWrappable = widget.dialogTheme?.isWrappable ?? false; + + final children = _isEditMode + ? [ + Text(widget.editLinkLabel ?? 'Visit link'.i18n), + UtilityWidgets.maybeWidget( + enabled: !isWrappable, + wrapper: (child) => Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: child, + ), + ), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: widget.childrenSpacing), + child: Link( + uri: Uri.parse(_linkController.text), + builder: (context, followLink) { + return TextButton( + onPressed: followLink, + style: TextButton.styleFrom( + backgroundColor: Colors.transparent, + ), + child: Text( + widget.link!, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + style: widget.dialogTheme?.inputTextStyle?.copyWith( + color: widget.linkColor ?? Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ); + }, + ), + ), + ), + ElevatedButton( + onPressed: () { + setState(() { + _isEditMode = !_isEditMode; + }); + }, + style: buttonStyle, + child: Text('Edit'.i18n), + ), + Padding( + padding: EdgeInsets.only(left: widget.childrenSpacing), + child: ElevatedButton( + onPressed: _removeLink, + style: buttonStyle, + child: Text('Remove'.i18n), + ), + ), + ] + : [ + Text(widget.addLinkLabel ?? 'Enter link'.i18n), + UtilityWidgets.maybeWidget( + enabled: !isWrappable, + wrapper: (child) => Expanded( + child: child, + ), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: widget.childrenSpacing), + child: TextFormField( + controller: _linkController, + style: widget.dialogTheme?.inputTextStyle, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelStyle: widget.dialogTheme?.labelTextStyle, + ), + autofocus: true, + autovalidateMode: widget.autovalidateMode, + validator: _validateLink, + onChanged: _linkChanged, + ), + ), + ), + ElevatedButton( + onPressed: _canPress() ? _applyLink : null, + style: buttonStyle, + child: Text('Apply'.i18n), + ), + ]; + + return Dialog( + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, + shape: widget.dialogTheme?.shape ?? + DialogTheme.of(context).shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + child: ConstrainedBox( + constraints: constraints, + child: Padding( + padding: widget.contentPadding, + child: isWrappable + ? Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: widget.dialogTheme?.runSpacing ?? 0.0, + children: children, + ) + : Row( + children: children, + ), + ), + ), + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + bool _canPress() => _validateLink(_link) == null; + + String? _validateLink(String? value) { + if ((value?.isEmpty ?? false) || + !AutoFormatMultipleLinksRule.linkRegExp.hasMatch(value!)) { + return widget.validationMessage ?? 'That is not a valid URL'; + } + + return null; + } + + void _applyLink() => + Navigator.pop(context, QuillTextLink(_text.trim(), _link.trim())); + + void _removeLink() => + Navigator.pop(context, QuillTextLink(_text.trim(), null)); +} + +/// Contains information about text URL. +class QuillTextLink { + QuillTextLink( + this.text, + this.link, + ); + + final String text; + final String? link; + + static QuillTextLink prepare(QuillController controller) { + final link = + controller.getSelectionStyle().attributes[Attribute.link.key]?.value; + final index = controller.selection.start; + + var text; + if (link != null) { + // text should be the link's corresponding text, not selection + final leaf = controller.document.querySegmentLeafNode(index).leaf; + if (leaf != null) { + text = leaf.toPlainText(); + } + } + + final len = controller.selection.end - index; + text ??= len == 0 ? '' : controller.document.getPlainText(index, len); + + return QuillTextLink(text, link); + } + + void submit(QuillController controller) { + var index = controller.selection.start; + var length = controller.selection.end - index; + final linkValue = + controller.getSelectionStyle().attributes[Attribute.link.key]?.value; + + if (linkValue != null) { + // text should be the link's corresponding text, not selection + final leaf = controller.document.querySegmentLeafNode(index).leaf; + if (leaf != null) { + final range = getLinkRange(leaf); + index = range.start; + length = range.end - range.start; + } + } + controller + ..replaceText(index, length, text, null) + ..formatText(index, text.length, LinkAttribute(link)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 38a3d504..a9a108c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" flutter: ">=3.0.0" dependencies: