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.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,49 +144,50 @@ 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,
required this.padding, required this.padding,
required this.autoFocus, required this.autoFocus,
required this.readOnly, required this.readOnly,
required this.expands, required this.expands,
this.showCursor, this.showCursor,
this.paintCursorAboveText, this.paintCursorAboveText,
this.placeholder, this.placeholder,
this.enableInteractiveSelection = true, this.enableInteractiveSelection = true,
this.enableSelectionToolbar = true, this.enableSelectionToolbar = true,
this.scrollBottomInset = 0, this.scrollBottomInset = 0,
this.minHeight, this.minHeight,
this.maxHeight, this.maxHeight,
this.maxContentWidth, this.maxContentWidth,
this.customStyles, this.customStyles,
this.textCapitalization = TextCapitalization.sentences, this.textCapitalization = TextCapitalization.sentences,
this.keyboardAppearance = Brightness.light, this.keyboardAppearance = Brightness.light,
this.scrollPhysics, this.scrollPhysics,
this.onLaunchUrl, this.onLaunchUrl,
this.onTapDown, this.onTapDown,
this.onTapUp, this.onTapUp,
this.onSingleLongTapStart, this.onSingleLongTapStart,
this.onSingleLongTapMoveUpdate, this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd, this.onSingleLongTapEnd,
this.embedBuilders, this.embedBuilders,
this.unknownEmbedBuilder, this.unknownEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.locale, this.locale,
this.floatingCursorDisabled = false, this.floatingCursorDisabled = false,
this.textSelectionControls, this.textSelectionControls,
this.onImagePaste, this.onImagePaste,
this.customShortcuts, this.customShortcuts,
this.customActions, this.customActions,
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,46 +42,48 @@ 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,
required this.cursorStyle, required this.cursorStyle,
required this.selectionColor, required this.selectionColor,
required this.selectionCtrls, required this.selectionCtrls,
required this.embedBuilder, required this.embedBuilder,
Key? key, Key? key,
this.scrollable = true, this.scrollable = true,
this.padding = EdgeInsets.zero, this.padding = EdgeInsets.zero,
this.readOnly = false, this.readOnly = false,
this.placeholder, this.placeholder,
this.onLaunchUrl, this.onLaunchUrl,
this.contextMenuBuilder = defaultContextMenuBuilder, this.contextMenuBuilder = defaultContextMenuBuilder,
this.showSelectionHandles = false, this.showSelectionHandles = false,
bool? showCursor, bool? showCursor,
this.textCapitalization = TextCapitalization.none, this.textCapitalization = TextCapitalization.none,
this.maxHeight, this.maxHeight,
this.minHeight, this.minHeight,
this.maxContentWidth, this.maxContentWidth,
this.customStyles, this.customStyles,
this.customShortcuts, this.customShortcuts,
this.customActions, this.customActions,
this.expands = false, this.expands = false,
this.autoFocus = false, this.autoFocus = false,
this.enableUnfocusOnTapOutside = true, this.enableUnfocusOnTapOutside = true,
this.keyboardAppearance = Brightness.light, this.keyboardAppearance = Brightness.light,
this.enableInteractiveSelection = true, this.enableInteractiveSelection = true,
this.scrollPhysics, this.scrollPhysics,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
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