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. 102
      lib/src/widgets/editor.dart
  5. 326
      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.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<T> {
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<T> {
static const String mobileAlignment = 'mobileAlignment';
static const ImageAttribute image = ImageAttribute(null);
static const VideoAttribute video = VideoAttribute(null);
static final Set<String> inlineKeys = {
Attribute.bold.key,
Attribute.italic.key,
@ -138,6 +144,11 @@ class Attribute<T> {
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?> 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
class ScriptAttribute extends Attribute<String> {
const ScriptAttribute(String val)
: super('script', AttributeScope.IGNORE, val);
class ScriptAttribute extends Attribute<String?> {
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<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';
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,
);
}

@ -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': 'نسخ الرابط',

@ -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 <String>[],
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 <String>[],
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<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 bool detectWordBoundary;
@ -412,6 +422,9 @@ class QuillEditor extends StatefulWidget {
/// Useful for deeplinks
final List<String> customLinkPrefixes;
/// Configures the dialog theme.
final QuillDialogTheme? dialogTheme;
@override
QuillEditorState createState() => QuillEditorState();
}
@ -511,6 +524,7 @@ class QuillEditorState extends State<QuillEditor>
customActions: widget.customActions,
customLinkPrefixes: widget.customLinkPrefixes,
enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside,
dialogTheme: widget.dialogTheme,
);
final editor = I18n(

@ -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 <String>[]})
: 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 <String>[],
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<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;
/// Builder function for embeddable objects.
@ -255,6 +265,9 @@ class RawEditor extends StatefulWidget {
final bool floatingCursorDisabled;
final List<String> customLinkPrefixes;
/// Configures the dialog theme.
final QuillDialogTheme? dialogTheme;
@override
State<StatefulWidget> 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: <LogicalKeySet, Intent>{
shortcuts: mergeMaps<ShortcutActivator, Intent>({
// 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<Type, Action<Intent>>(_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<ApplyCheckListIntent> {
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
/// [RawEditorState].
///

@ -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';

@ -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
environment:
sdk: ">=2.12.0 <3.0.0"
sdk: ">=2.17.0 <3.0.0"
flutter: ">=3.0.0"
dependencies:

Loading…
Cancel
Save