Editor tweaks (#1185)

pull/1186/head
BambinoUA 2 years ago committed by GitHub
parent f2a1a1f45a
commit 411f911dfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 38
      lib/src/models/documents/attribute.dart
  2. 102
      lib/src/models/themes/quill_dialog_theme.dart
  3. 8
      lib/src/translations/toolbar.i18n.dart
  4. 24
      lib/src/widgets/editor.dart
  5. 260
      lib/src/widgets/raw_editor.dart
  6. 1
      lib/src/widgets/toolbar.dart
  7. 447
      lib/src/widgets/toolbar/link_style_button2.dart
  8. 2
      pubspec.yaml

@ -42,6 +42,8 @@ class Attribute<T> {
Attribute.style.key: Attribute.style, Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token, Attribute.token.key: Attribute.token,
Attribute.script.key: Attribute.script, Attribute.script.key: Attribute.script,
Attribute.image.key: Attribute.image,
Attribute.video.key: Attribute.video,
}); });
static const BoldAttribute bold = BoldAttribute(); static const BoldAttribute bold = BoldAttribute();
@ -90,7 +92,7 @@ class Attribute<T> {
static const TokenAttribute token = TokenAttribute(''); static const TokenAttribute token = TokenAttribute('');
static const ScriptAttribute script = ScriptAttribute(''); static final ScriptAttribute script = ScriptAttribute(null);
static const String mobileWidth = 'mobileWidth'; static const String mobileWidth = 'mobileWidth';
@ -100,6 +102,10 @@ class Attribute<T> {
static const String mobileAlignment = 'mobileAlignment'; static const String mobileAlignment = 'mobileAlignment';
static const ImageAttribute image = ImageAttribute(null);
static const VideoAttribute video = VideoAttribute(null);
static final Set<String> inlineKeys = { static final Set<String> inlineKeys = {
Attribute.bold.key, Attribute.bold.key,
Attribute.italic.key, Attribute.italic.key,
@ -138,6 +144,11 @@ class Attribute<T> {
Attribute.blockQuote.key, Attribute.blockQuote.key,
}); });
static final Set<String> embedKeys = {
Attribute.image.key,
Attribute.video.key,
};
static const Attribute<int?> h1 = HeaderAttribute(level: 1); static const Attribute<int?> h1 = HeaderAttribute(level: 1);
static const Attribute<int?> h2 = HeaderAttribute(level: 2); static const Attribute<int?> h2 = HeaderAttribute(level: 2);
@ -346,7 +357,26 @@ class TokenAttribute extends Attribute<String> {
} }
// `script` is supposed to be inline attribute but it is not supported yet // `script` is supposed to be inline attribute but it is not supported yet
class ScriptAttribute extends Attribute<String> { class ScriptAttribute extends Attribute<String?> {
const ScriptAttribute(String val) ScriptAttribute(ScriptAttributes? val)
: super('script', AttributeScope.IGNORE, val); : super('script', AttributeScope.IGNORE, val?.value);
}
enum ScriptAttributes {
sup('super'),
sub('sup');
const ScriptAttributes(this.value);
final String value;
}
class ImageAttribute extends Attribute<String?> {
const ImageAttribute(String? url)
: super('image', AttributeScope.EMBEDS, url);
}
class VideoAttribute extends Attribute<String?> {
const VideoAttribute(String? url)
: super('video', AttributeScope.EMBEDS, url);
} }

@ -1,8 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class QuillDialogTheme { /// Used to configure the dialog's look and feel.
QuillDialogTheme( class QuillDialogTheme with Diagnosticable {
{this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor}); 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 ///The text style to use for the label shown in the link-input dialog
final TextStyle? labelTextStyle; final TextStyle? labelTextStyle;
@ -10,6 +21,89 @@ class QuillDialogTheme {
///The text style to use for the input text shown in the link-input dialog ///The text style to use for the input text shown in the link-input dialog
final TextStyle? inputTextStyle; final TextStyle? inputTextStyle;
///The background color for the [LinkDialog()] ///The background color for the Quill dialog
final Color? dialogBackgroundColor; 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,
);
} }

@ -61,6 +61,10 @@ extension Localization on String {
'Increase indent': 'Increase indent', 'Increase indent': 'Increase indent',
'Decrease indent': 'Decrease indent', 'Decrease indent': 'Decrease indent',
'Insert URL': 'Insert URL', 'Insert URL': 'Insert URL',
'Visit link': 'Visit link',
'Enter link': 'Enter link',
'Edit': 'Edit',
'Apply': 'Apply',
}, },
'en_us': { 'en_us': {
'Paste a link': 'Paste a link', 'Paste a link': 'Paste a link',
@ -120,6 +124,10 @@ extension Localization on String {
'Increase indent': 'Increase indent', 'Increase indent': 'Increase indent',
'Decrease indent': 'Decrease indent', 'Decrease indent': 'Decrease indent',
'Insert URL': 'Insert URL', 'Insert URL': 'Insert URL',
'Visit link': 'Visit link',
'Enter link': 'Enter link',
'Edit': 'Edit',
'Apply': 'Apply',
}, },
'ar': { 'ar': {
'Paste a link': 'نسخ الرابط', 'Paste a link': 'نسخ الرابط',

@ -15,6 +15,7 @@ import '../models/documents/nodes/container.dart' as container_node;
import '../models/documents/nodes/leaf.dart'; import '../models/documents/nodes/leaf.dart';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../models/structs/offset_value.dart'; import '../models/structs/offset_value.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../utils/platform.dart'; import '../utils/platform.dart';
import 'box.dart'; import 'box.dart';
import 'controller.dart'; import 'controller.dart';
@ -143,8 +144,8 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics {
} }
class QuillEditor extends StatefulWidget { class QuillEditor extends StatefulWidget {
const QuillEditor( const QuillEditor({
{required this.controller, required this.controller,
required this.focusNode, required this.focusNode,
required this.scrollController, required this.scrollController,
required this.scrollable, required this.scrollable,
@ -184,8 +185,9 @@ class QuillEditor extends StatefulWidget {
this.detectWordBoundary = true, this.detectWordBoundary = true,
this.enableUnfocusOnTapOutside = true, this.enableUnfocusOnTapOutside = true,
this.customLinkPrefixes = const <String>[], this.customLinkPrefixes = const <String>[],
Key? key}) this.dialogTheme,
: super(key: key); Key? key,
}) : super(key: key);
factory QuillEditor.basic({ factory QuillEditor.basic({
required QuillController controller, required QuillController controller,
@ -302,6 +304,7 @@ class QuillEditor extends StatefulWidget {
/// horizontally centered. This is mostly useful on devices with wide screens. /// horizontally centered. This is mostly useful on devices with wide screens.
final double? maxContentWidth; final double? maxContentWidth;
/// Allows to override [DefaultStyles].
final DefaultStyles? customStyles; final DefaultStyles? customStyles;
/// Whether this editor's height will be sized to fill its parent. /// 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. /// Returns the url of the image if the image should be inserted.
final Future<String?> Function(Uint8List imageBytes)? onImagePaste; final Future<String?> Function(Uint8List imageBytes)? onImagePaste;
final Map<LogicalKeySet, Intent>? customShortcuts; /// Contains user-defined shortcuts map.
///
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts]
final Map<ShortcutActivator, Intent>? customShortcuts;
/// Contains user-defined actions.
///
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions]
final Map<Type, Action<Intent>>? customActions; final Map<Type, Action<Intent>>? customActions;
final bool detectWordBoundary; final bool detectWordBoundary;
@ -412,6 +422,9 @@ class QuillEditor extends StatefulWidget {
/// Useful for deeplinks /// Useful for deeplinks
final List<String> customLinkPrefixes; final List<String> customLinkPrefixes;
/// Configures the dialog theme.
final QuillDialogTheme? dialogTheme;
@override @override
QuillEditorState createState() => QuillEditorState(); QuillEditorState createState() => QuillEditorState();
} }
@ -511,6 +524,7 @@ class QuillEditorState extends State<QuillEditor>
customActions: widget.customActions, customActions: widget.customActions,
customLinkPrefixes: widget.customLinkPrefixes, customLinkPrefixes: widget.customLinkPrefixes,
enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside, enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside,
dialogTheme: widget.dialogTheme,
); );
final editor = I18n( final editor = I18n(

@ -2,10 +2,9 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
// ignore: unnecessary_import
import 'dart:typed_data';
import 'dart:ui' as ui hide TextStyle; import 'dart:ui' as ui hide TextStyle;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -24,6 +23,7 @@ import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../models/structs/offset_value.dart'; import '../models/structs/offset_value.dart';
import '../models/structs/vertical_spacing.dart'; import '../models/structs/vertical_spacing.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../utils/cast.dart'; import '../utils/cast.dart';
import '../utils/delta.dart'; import '../utils/delta.dart';
import '../utils/embeds.dart'; import '../utils/embeds.dart';
@ -42,11 +42,12 @@ import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
import 'text_block.dart'; import 'text_block.dart';
import 'text_line.dart'; import 'text_line.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'toolbar/link_style_button2.dart';
import 'toolbar/search_dialog.dart'; import 'toolbar/search_dialog.dart';
class RawEditor extends StatefulWidget { class RawEditor extends StatefulWidget {
const RawEditor( const RawEditor({
{required this.controller, required this.controller,
required this.focusNode, required this.focusNode,
required this.scrollController, required this.scrollController,
required this.scrollBottomInset, required this.scrollBottomInset,
@ -80,8 +81,9 @@ class RawEditor extends StatefulWidget {
this.customStyleBuilder, this.customStyleBuilder,
this.floatingCursorDisabled = false, this.floatingCursorDisabled = false,
this.onImagePaste, this.onImagePaste,
this.customLinkPrefixes = const <String>[]}) this.customLinkPrefixes = const <String>[],
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), this.dialogTheme,
}) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
'maxHeight cannot be null'), 'maxHeight cannot be null'),
@ -190,6 +192,7 @@ class RawEditor extends StatefulWidget {
/// horizontally centered. This is mostly useful on devices with wide screens. /// horizontally centered. This is mostly useful on devices with wide screens.
final double? maxContentWidth; final double? maxContentWidth;
/// Allows to override [DefaultStyles].
final DefaultStyles? customStyles; final DefaultStyles? customStyles;
/// Whether this widget's height will be sized to fill its parent. /// Whether this widget's height will be sized to fill its parent.
@ -245,7 +248,14 @@ class RawEditor extends StatefulWidget {
final Future<String?> Function(Uint8List imageBytes)? onImagePaste; final Future<String?> Function(Uint8List imageBytes)? onImagePaste;
final Map<LogicalKeySet, Intent>? customShortcuts; /// Contains user-defined shortcuts map.
///
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts]
final Map<ShortcutActivator, Intent>? customShortcuts;
/// Contains user-defined actions.
///
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions]
final Map<Type, Action<Intent>>? customActions; final Map<Type, Action<Intent>>? customActions;
/// Builder function for embeddable objects. /// Builder function for embeddable objects.
@ -255,6 +265,9 @@ class RawEditor extends StatefulWidget {
final bool floatingCursorDisabled; final bool floatingCursorDisabled;
final List<String> customLinkPrefixes; final List<String> customLinkPrefixes;
/// Configures the dialog theme.
final QuillDialogTheme? dialogTheme;
@override @override
State<StatefulWidget> createState() => RawEditorState(); State<StatefulWidget> createState() => RawEditorState();
} }
@ -495,78 +508,148 @@ class RawEditorState extends EditorState
minHeight: widget.minHeight ?? 0.0, minHeight: widget.minHeight ?? 0.0,
maxHeight: widget.maxHeight ?? double.infinity); maxHeight: widget.maxHeight ?? double.infinity);
final isMacOS = Theme.of(context).platform == TargetPlatform.macOS;
return TextFieldTapRegion( return TextFieldTapRegion(
enabled: widget.enableUnfocusOnTapOutside, enabled: widget.enableUnfocusOnTapOutside,
onTapOutside: _defaultOnTapOutside, onTapOutside: _defaultOnTapOutside,
child: QuillStyles( child: QuillStyles(
data: _styles!, data: _styles!,
child: Shortcuts( child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: mergeMaps<ShortcutActivator, Intent>({
// shortcuts added for Desktop platforms. // shortcuts added for Desktop platforms.
LogicalKeySet(LogicalKeyboardKey.escape): const SingleActivator(
const HideSelectionToolbarIntent(), LogicalKeyboardKey.escape,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): ): const HideSelectionToolbarIntent(),
const UndoTextIntent(SelectionChangedCause.keyboard), SingleActivator(
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyY): LogicalKeyboardKey.keyZ,
const RedoTextIntent(SelectionChangedCause.keyboard), control: !isMacOS,
meta: isMacOS,
): const UndoTextIntent(SelectionChangedCause.keyboard),
SingleActivator(
LogicalKeyboardKey.keyY,
control: !isMacOS,
meta: isMacOS,
): const RedoTextIntent(SelectionChangedCause.keyboard),
// Selection formatting. // Selection formatting.
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB): SingleActivator(
const ToggleTextStyleIntent(Attribute.bold), LogicalKeyboardKey.keyB,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyU): control: !isMacOS,
const ToggleTextStyleIntent(Attribute.underline), meta: isMacOS,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI): ): const ToggleTextStyleIntent(Attribute.bold),
const ToggleTextStyleIntent(Attribute.italic), SingleActivator(
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyU,
LogicalKeyboardKey.keyS): control: !isMacOS,
const ToggleTextStyleIntent(Attribute.strikeThrough), meta: isMacOS,
LogicalKeySet( ): const ToggleTextStyleIntent(Attribute.underline),
LogicalKeyboardKey.control, LogicalKeyboardKey.backquote): SingleActivator(
const ToggleTextStyleIntent(Attribute.inlineCode), LogicalKeyboardKey.keyI,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL): control: !isMacOS,
const ToggleTextStyleIntent(Attribute.ul), meta: isMacOS,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyO): ): const ToggleTextStyleIntent(Attribute.italic),
const ToggleTextStyleIntent(Attribute.ol), SingleActivator(
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyS,
LogicalKeyboardKey.keyB): control: !isMacOS,
const ToggleTextStyleIntent(Attribute.blockQuote), meta: isMacOS,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, shift: true,
LogicalKeyboardKey.tilde): ): const ToggleTextStyleIntent(Attribute.strikeThrough),
const ToggleTextStyleIntent(Attribute.codeBlock), SingleActivator(
// Indent LogicalKeyboardKey.backquote,
LogicalKeySet(LogicalKeyboardKey.control, control: !isMacOS,
LogicalKeyboardKey.bracketRight): meta: isMacOS,
const IndentSelectionIntent(true), ): const ToggleTextStyleIntent(Attribute.inlineCode),
LogicalKeySet( SingleActivator(
LogicalKeyboardKey.control, LogicalKeyboardKey.bracketLeft): LogicalKeyboardKey.tilde,
const IndentSelectionIntent(false), control: !isMacOS,
meta: isMacOS,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): shift: true,
const OpenSearchIntent(), ): const ToggleTextStyleIntent(Attribute.codeBlock),
SingleActivator(
LogicalKeySet( LogicalKeyboardKey.keyB,
LogicalKeyboardKey.control, LogicalKeyboardKey.digit1): control: !isMacOS,
const ApplyHeaderIntent(Attribute.h1), meta: isMacOS,
LogicalKeySet( shift: true,
LogicalKeyboardKey.control, LogicalKeyboardKey.digit2): ): const ToggleTextStyleIntent(Attribute.blockQuote),
const ApplyHeaderIntent(Attribute.h2), SingleActivator(
LogicalKeySet( LogicalKeyboardKey.keyK,
LogicalKeyboardKey.control, LogicalKeyboardKey.digit3): control: !isMacOS,
const ApplyHeaderIntent(Attribute.h3), meta: isMacOS,
LogicalKeySet( ): const ApplyLinkIntent(),
LogicalKeyboardKey.control, LogicalKeyboardKey.digit0):
const ApplyHeaderIntent(Attribute.header), // Lists
SingleActivator(
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyL,
LogicalKeyboardKey.keyL): const ApplyCheckListIntent(), control: !isMacOS,
meta: isMacOS,
if (widget.customShortcuts != null) ...widget.customShortcuts!, 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( child: Actions(
actions: { actions: mergeMaps<Type, Action<Intent>>(_actions, {
..._actions, ...?widget.customActions,
if (widget.customActions != null) ...widget.customActions!, }),
},
child: Focus( child: Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
onKey: _onKey, onKey: _onKey,
@ -1570,11 +1653,13 @@ class RawEditorState extends EditorState
RedoTextIntent: _makeOverridable(_RedoKeyboardAction(this)), RedoTextIntent: _makeOverridable(_RedoKeyboardAction(this)),
OpenSearchIntent: _openSearchAction, OpenSearchIntent: _openSearchAction,
// Selection Formatting // Selection Formatting
ToggleTextStyleIntent: _formatSelectionAction, ToggleTextStyleIntent: _formatSelectionAction,
IndentSelectionIntent: _indentSelectionAction, IndentSelectionIntent: _indentSelectionAction,
ApplyHeaderIntent: _applyHeaderAction, ApplyHeaderIntent: _applyHeaderAction,
ApplyCheckListIntent: _applyCheckListAction, ApplyCheckListIntent: _applyCheckListAction,
ApplyLinkIntent: ApplyLinkAction(this)
}; };
@override @override
@ -2490,6 +2575,43 @@ class _ApplyCheckListAction extends Action<ApplyCheckListIntent> {
bool get isActionEnabled => true; bool get isActionEnabled => true;
} }
class ApplyLinkIntent extends Intent {
const ApplyLinkIntent();
}
class ApplyLinkAction extends Action<ApplyLinkIntent> {
ApplyLinkAction(this.state);
final RawEditorState state;
@override
Object? invoke(ApplyLinkIntent intent) async {
final initialTextLink = QuillTextLink.prepare(state.controller);
final textLink = await showDialog<QuillTextLink>(
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 /// Signature for a widget builder that builds a context menu for the given
/// [RawEditorState]. /// [RawEditorState].
/// ///

@ -29,6 +29,7 @@ export 'toolbar/color_button.dart';
export 'toolbar/history_button.dart'; export 'toolbar/history_button.dart';
export 'toolbar/indent_button.dart'; export 'toolbar/indent_button.dart';
export 'toolbar/link_style_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_family_button.dart';
export 'toolbar/quill_font_size_button.dart'; export 'toolbar/quill_font_size_button.dart';
export 'toolbar/quill_icon_button.dart'; export 'toolbar/quill_icon_button.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<LinkStyleButton2> createState() => _LinkStyleButton2State();
}
class _LinkStyleButton2State extends State<LinkStyleButton2> {
@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<void> _openLinkDialog() async {
final initialTextLink = QuillTextLink.prepare(widget.controller);
final textLink = await showDialog<QuillTextLink>(
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<LinkStyleDialog> createState() => _LinkStyleDialogState();
}
class _LinkStyleDialogState extends State<LinkStyleDialog> {
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));
}
}

@ -6,7 +6,7 @@ homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill repository: https://github.com/singerdmx/flutter-quill
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
flutter: ">=3.0.0" flutter: ">=3.0.0"
dependencies: dependencies:

Loading…
Cancel
Save