Merge branch 'singerdmx:master' into master

pull/1270/head
Cierra_Runis 2 years ago committed by GitHub
commit f93bf17c2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 934
      CHANGELOG.md
  2. 9
      flutter_quill_extensions/CHANGELOG.md
  3. 38
      flutter_quill_extensions/lib/embeds/builders.dart
  4. 26
      flutter_quill_extensions/lib/embeds/embed_types.dart
  5. 452
      flutter_quill_extensions/lib/embeds/toolbar/media_button.dart
  6. 2
      flutter_quill_extensions/lib/embeds/widgets/image.dart
  7. 18
      flutter_quill_extensions/lib/flutter_quill_extensions.dart
  8. 23
      flutter_quill_extensions/lib/shims/dart_ui_fake.dart
  9. 1
      flutter_quill_extensions/lib/shims/dart_ui_real.dart
  10. 7
      flutter_quill_extensions/pubspec.yaml
  11. 1
      lib/extensions.dart
  12. 49
      lib/src/models/documents/attribute.dart
  13. 6
      lib/src/models/documents/document.dart
  14. 38
      lib/src/models/documents/nodes/line.dart
  15. 3
      lib/src/models/structs/offset_value.dart
  16. 120
      lib/src/models/themes/quill_dialog_theme.dart
  17. 1001
      lib/src/translations/toolbar.i18n.dart
  18. 40
      lib/src/widgets/controller.dart
  19. 11
      lib/src/widgets/default_styles.dart
  20. 3
      lib/src/widgets/delegate.dart
  21. 27
      lib/src/widgets/editor.dart
  22. 285
      lib/src/widgets/raw_editor.dart
  23. 39
      lib/src/widgets/text_line.dart
  24. 25
      lib/src/widgets/toolbar.dart
  25. 2
      lib/src/widgets/toolbar/enum.dart
  26. 446
      lib/src/widgets/toolbar/link_style_button2.dart
  27. 3
      lib/src/widgets/toolbar/toggle_style_button.dart
  28. 4
      pubspec.yaml

File diff suppressed because it is too large Load Diff

@ -1,3 +1,12 @@
## 0.3.1
* Image embedding tweaks
* Add MediaButton which is intened to superseed the ImageButton and VideoButton. Only image selection is working.
* Implement image insert for web (image as base64)
## 0.3.0
* Added support for adding custom tooltips to toolbar buttons
## 0.2.0 ## 0.2.0
* Allow widgets to override widget span properties [b7951b0](https://github.com/singerdmx/flutter-quill/commit/b7951b02c9086ea42e7aad6d78e6c9b0297562e5) * Allow widgets to override widget span properties [b7951b0](https://github.com/singerdmx/flutter-quill/commit/b7951b02c9086ea42e7aad6d78e6c9b0297562e5)

@ -7,7 +7,10 @@ import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill/translations.dart'; import 'package:flutter_quill/translations.dart';
import 'package:gallery_saver/gallery_saver.dart'; import 'package:gallery_saver/gallery_saver.dart';
import 'package:math_keyboard/math_keyboard.dart'; import 'package:math_keyboard/math_keyboard.dart';
import 'package:universal_html/html.dart' as html;
import '../shims/dart_ui_fake.dart'
if (dart.library.html) '../shims/dart_ui_real.dart' as ui;
import 'utils.dart'; import 'utils.dart';
import 'widgets/image.dart'; import 'widgets/image.dart';
import 'widgets/image_resizer.dart'; import 'widgets/image_resizer.dart';
@ -145,6 +148,41 @@ class ImageEmbedBuilder extends EmbedBuilder {
} }
} }
class ImageEmbedBuilderWeb extends EmbedBuilder {
ImageEmbedBuilderWeb({this.constraints})
: assert(kIsWeb, 'ImageEmbedBuilderWeb is only for web platform');
final BoxConstraints? constraints;
@override
String get key => BlockEmbed.imageType;
@override
Widget build(
BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
bool inline,
) {
final imageUrl = node.value.data;
ui.platformViewRegistry.registerViewFactory(imageUrl, (viewId) {
return html.ImageElement()
..src = imageUrl
..style.height = 'auto'
..style.width = 'auto';
});
return ConstrainedBox(
constraints: constraints ?? BoxConstraints.loose(const Size(200, 200)),
child: HtmlElementView(
viewType: imageUrl,
),
);
}
}
class VideoEmbedBuilder extends EmbedBuilder { class VideoEmbedBuilder extends EmbedBuilder {
VideoEmbedBuilder({this.onVideoInit}); VideoEmbedBuilder({this.onVideoInit});

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -18,3 +19,28 @@ enum MediaPickSetting {
Camera, Camera,
Video, Video,
} }
typedef MediaFileUrl = String;
typedef MediaFilePicker = Future<QuillFile?> Function(QuillMediaType mediaType);
typedef MediaPickedCallback = Future<MediaFileUrl> Function(QuillFile file);
enum QuillMediaType { image, video }
extension QuillMediaTypeX on QuillMediaType {
bool get isImage => this == QuillMediaType.image;
bool get isVideo => this == QuillMediaType.video;
}
/// Represents a file data which returned by file picker.
class QuillFile {
QuillFile({
required this.name,
this.path = '',
Uint8List? bytes,
}) : assert(name.isNotEmpty),
bytes = bytes ?? Uint8List(0);
final String name;
final String path;
final Uint8List bytes;
}

@ -0,0 +1,452 @@
//import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/extensions.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill/translations.dart';
import 'package:image_picker/image_picker.dart';
import '../embed_types.dart';
/// Widget which combines [ImageButton] and [VideButton] widgets. This widget
/// has more customization and uses dialog similar to one which is used
/// on [http://quilljs.com].
class MediaButton extends StatelessWidget {
const MediaButton({
required this.controller,
required this.icon,
this.type = QuillMediaType.image,
this.iconSize = kDefaultIconSize,
this.fillColor,
this.mediaFilePicker = _defaultMediaPicker,
this.onMediaPickedCallback,
this.iconTheme,
this.dialogTheme,
this.tooltip,
this.childrenSpacing = 16.0,
this.labelText,
this.hintText,
this.submitButtonText,
this.submitButtonSize,
this.galleryButtonText,
this.linkButtonText,
this.autovalidateMode = AutovalidateMode.disabled,
Key? key,
this.validationMessage,
}) : assert(type == QuillMediaType.image,
'Video selection is not supported yet'),
super(key: key);
final QuillController controller;
final IconData icon;
final double iconSize;
final Color? fillColor;
final QuillMediaType type;
final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme;
final String? tooltip;
final MediaFilePicker mediaFilePicker;
final MediaPickedCallback? onMediaPickedCallback;
/// The margin between child widgets in the dialog.
final double childrenSpacing;
/// The text of label in link add mode.
final String? labelText;
/// The hint text for link [TextField].
final String? hintText;
/// The text of the submit button.
final String? submitButtonText;
/// The size of dialog buttons.
final Size? submitButtonSize;
/// The text of the gallery button [MediaSourceSelectorDialog].
final String? galleryButtonText;
/// The text of the link button [MediaSourceSelectorDialog].
final String? linkButtonText;
final AutovalidateMode autovalidateMode;
final String? validationMessage;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final iconFillColor =
iconTheme?.iconUnselectedFillColor ?? fillColor ?? theme.canvasColor;
return QuillIconButton(
icon: Icon(icon, size: iconSize, color: iconColor),
tooltip: tooltip,
highlightElevation: 0,
hoverElevation: 0,
size: iconSize * 1.77,
fillColor: iconFillColor,
borderRadius: iconTheme?.borderRadius ?? 2,
onPressed: () => _onPressedHandler(context),
);
}
Future<void> _onPressedHandler(BuildContext context) async {
if (onMediaPickedCallback != null) {
final mediaSource = await showDialog<MediaPickSetting>(
context: context,
builder: (_) => MediaSourceSelectorDialog(
dialogTheme: dialogTheme,
galleryButtonText: galleryButtonText,
linkButtonText: linkButtonText,
),
);
if (mediaSource != null) {
if (mediaSource == MediaPickSetting.Gallery) {
await _pickImage();
} else {
_inputLink(context);
}
}
} else {
_inputLink(context);
}
}
Future<void> _pickImage() async {
if (!(kIsWeb || isMobile() || isDesktop())) {
throw UnsupportedError(
'Unsupported target platform: ${defaultTargetPlatform.name}');
}
final mediaFileUrl = await _pickMediaFileUrl();
if (mediaFileUrl != null) {
final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index;
controller.replaceText(
index, length, BlockEmbed.image(mediaFileUrl), null);
}
}
Future<MediaFileUrl?> _pickMediaFileUrl() async {
final mediaFile = await mediaFilePicker(type);
return mediaFile != null ? onMediaPickedCallback?.call(mediaFile) : null;
}
void _inputLink(BuildContext context) {
showDialog<String>(
context: context,
builder: (_) => MediaLinkDialog(
dialogTheme: dialogTheme,
labelText: labelText,
hintText: hintText,
buttonText: submitButtonText,
buttonSize: submitButtonSize,
childrenSpacing: childrenSpacing,
autovalidateMode: autovalidateMode,
validationMessage: validationMessage,
),
).then(_linkSubmitted);
}
void _linkSubmitted(String? value) {
if (value != null && value.isNotEmpty) {
final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index;
final data =
type.isImage ? BlockEmbed.image(value) : BlockEmbed.video(value);
controller.replaceText(index, length, data, null);
}
}
}
/// Provides a dialog for input link to media resource.
class MediaLinkDialog extends StatefulWidget {
const MediaLinkDialog({
Key? key,
this.link,
this.dialogTheme,
this.childrenSpacing = 16.0,
this.labelText,
this.hintText,
this.buttonText,
this.buttonSize,
this.autovalidateMode = AutovalidateMode.disabled,
this.validationMessage,
}) : assert(childrenSpacing > 0),
super(key: key);
final String? link;
final QuillDialogTheme? dialogTheme;
/// The margin between child widgets in the dialog.
final double childrenSpacing;
/// The text of label in link add mode.
final String? labelText;
/// The hint text for link [TextField].
final String? hintText;
/// The text of the submit button.
final String? buttonText;
/// The size of dialog buttons.
final Size? buttonSize;
final AutovalidateMode autovalidateMode;
final String? validationMessage;
@override
State<MediaLinkDialog> createState() => _MediaLinkDialogState();
}
class _MediaLinkDialogState extends State<MediaLinkDialog> {
final _linkFocus = FocusNode();
final _linkController = TextEditingController();
@override
void dispose() {
_linkFocus.dispose();
_linkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final 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 = [
Text(widget.labelText ?? 'Enter media'.i18n),
UtilityWidgets.maybeWidget(
enabled: !isWrappable,
wrapper: (child) => Expanded(
child: child,
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: widget.childrenSpacing),
child: TextFormField(
controller: _linkController,
focusNode: _linkFocus,
style: widget.dialogTheme?.inputTextStyle,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelStyle: widget.dialogTheme?.labelTextStyle,
hintText: widget.hintText,
),
autofocus: true,
autovalidateMode: widget.autovalidateMode,
validator: _validateLink,
onChanged: _linkChanged,
),
),
),
ElevatedButton(
onPressed: _canPress() ? _submitLink : null,
style: buttonStyle,
child: Text(widget.buttonText ?? 'Ok'.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.dialogTheme?.linkDialogPadding ?? const EdgeInsets.all(16),
child: isWrappable
? Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: widget.dialogTheme?.runSpacing ?? 0.0,
children: children,
)
: Row(
children: children,
),
),
),
);
}
bool _canPress() => _validateLink(_linkController.text) == null;
void _linkChanged(String value) {
setState(() {
_linkController.text = value;
});
}
void _submitLink() => Navigator.pop(context, _linkController.text);
String? _validateLink(String? value) {
if ((value?.isEmpty ?? false) ||
!AutoFormatMultipleLinksRule.linkRegExp.hasMatch(value!)) {
return widget.validationMessage ?? 'That is not a valid URL';
}
return null;
}
}
/// Media souce selector.
class MediaSourceSelectorDialog extends StatelessWidget {
const MediaSourceSelectorDialog({
Key? key,
this.dialogTheme,
this.galleryButtonText,
this.linkButtonText,
}) : super(key: key);
final QuillDialogTheme? dialogTheme;
/// The text of the gallery button [MediaSourceSelectorDialog].
final String? galleryButtonText;
/// The text of the link button [MediaSourceSelectorDialog].
final String? linkButtonText;
@override
Widget build(BuildContext context) {
final constraints = dialogTheme?.mediaSelectorDialogConstraints ??
() {
final mediaQuery = MediaQuery.of(context);
double maxWidth, maxHeight;
if (kIsWeb) {
maxWidth = mediaQuery.size.width / 7;
maxHeight = mediaQuery.size.height / 7;
} else {
maxWidth = mediaQuery.size.width - 80;
maxHeight = maxWidth / 2;
}
return BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight);
}();
final shape = dialogTheme?.shape ??
DialogTheme.of(context).shape ??
RoundedRectangleBorder(borderRadius: BorderRadius.circular(4));
return Dialog(
backgroundColor: dialogTheme?.dialogBackgroundColor,
shape: shape,
child: ConstrainedBox(
constraints: constraints,
child: Padding(
padding: dialogTheme?.mediaSelectorDialogPadding ??
const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: TextButtonWithIcon(
icon: Icons.collections,
label: galleryButtonText ?? 'Gallery'.i18n,
onPressed: () =>
Navigator.pop(context, MediaPickSetting.Gallery),
),
),
const SizedBox(width: 10),
Expanded(
child: TextButtonWithIcon(
icon: Icons.link,
label: linkButtonText ?? 'Link'.i18n,
onPressed: () =>
Navigator.pop(context, MediaPickSetting.Link),
),
)
],
),
),
),
);
}
}
class TextButtonWithIcon extends StatelessWidget {
const TextButtonWithIcon({
required this.label,
required this.icon,
required this.onPressed,
this.textStyle,
Key? key,
}) : super(key: key);
final String label;
final IconData icon;
final VoidCallback onPressed;
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1;
final gap = scale <= 1 ? 8.0 : lerpDouble(8, 4, math.min(scale - 1, 1))!;
final buttonStyle = TextButtonTheme.of(context).style;
final shape = buttonStyle?.shape?.resolve({}) ??
const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4)));
return Material(
shape: shape,
textStyle: textStyle ??
theme.textButtonTheme.style?.textStyle?.resolve({}) ??
theme.textTheme.labelLarge,
elevation: buttonStyle?.elevation?.resolve({}) ?? 0,
child: InkWell(
customBorder: shape,
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon),
SizedBox(height: gap),
Flexible(child: Text(label)),
],
),
),
),
);
}
}
/// Default file picker.
Future<QuillFile?> _defaultMediaPicker(QuillMediaType mediaType) async {
final pickedFile = mediaType.isImage
? await ImagePicker().pickImage(source: ImageSource.gallery)
: await ImagePicker().pickVideo(source: ImageSource.gallery);
if (pickedFile != null) {
return QuillFile(
name: pickedFile.name,
path: pickedFile.path,
bytes: await pickedFile.readAsBytes(),
);
}
return null;
}

@ -21,7 +21,7 @@ String getImageStyleString(QuillController controller) {
final String? s = controller final String? s = controller
.getAllSelectionStyles() .getAllSelectionStyles()
.firstWhere((s) => s.attributes.containsKey(Attribute.style.key), .firstWhere((s) => s.attributes.containsKey(Attribute.style.key),
orElse: () => Style()) orElse: Style.new)
.attributes[Attribute.style.key] .attributes[Attribute.style.key]
?.value; ?.value;
return s ?? ''; return s ?? '';

@ -15,18 +15,24 @@ export 'embeds/toolbar/camera_button.dart';
export 'embeds/toolbar/formula_button.dart'; export 'embeds/toolbar/formula_button.dart';
export 'embeds/toolbar/image_button.dart'; export 'embeds/toolbar/image_button.dart';
export 'embeds/toolbar/image_video_utils.dart'; export 'embeds/toolbar/image_video_utils.dart';
export 'embeds/toolbar/media_button.dart';
export 'embeds/toolbar/video_button.dart'; export 'embeds/toolbar/video_button.dart';
export 'embeds/utils.dart'; export 'embeds/utils.dart';
class FlutterQuillEmbeds { class FlutterQuillEmbeds {
static List<EmbedBuilder> builders( static List<EmbedBuilder> builders({
{void Function(GlobalKey videoContainerKey)? onVideoInit}) => void Function(GlobalKey videoContainerKey)? onVideoInit,
}) =>
[ [
ImageEmbedBuilder(), ImageEmbedBuilder(),
VideoEmbedBuilder(onVideoInit: onVideoInit), VideoEmbedBuilder(onVideoInit: onVideoInit),
FormulaEmbedBuilder(), FormulaEmbedBuilder(),
]; ];
static List<EmbedBuilder> webBuilders() => [
ImageEmbedBuilderWeb(),
];
static List<EmbedButtonBuilder> buttons({ static List<EmbedButtonBuilder> buttons({
bool showImageButton = true, bool showImageButton = true,
bool showVideoButton = true, bool showVideoButton = true,
@ -43,8 +49,8 @@ class FlutterQuillEmbeds {
FilePickImpl? filePickImpl, FilePickImpl? filePickImpl,
WebImagePickImpl? webImagePickImpl, WebImagePickImpl? webImagePickImpl,
WebVideoPickImpl? webVideoPickImpl, WebVideoPickImpl? webVideoPickImpl,
}) { }) =>
return [ [
if (showImageButton) if (showImageButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton( (controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton(
icon: Icons.image, icon: Icons.image,
@ -87,7 +93,8 @@ class FlutterQuillEmbeds {
iconTheme: iconTheme, iconTheme: iconTheme,
), ),
if (showFormulaButton) if (showFormulaButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => FormulaButton( (controller, toolbarIconSize, iconTheme, dialogTheme) =>
FormulaButton(
icon: Icons.functions, icon: Icons.functions,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: formulaButtonTooltip, tooltip: formulaButtonTooltip,
@ -97,4 +104,3 @@ class FlutterQuillEmbeds {
) )
]; ];
} }
}

@ -0,0 +1,23 @@
// ignore_for_file: avoid_classes_with_only_static_members, camel_case_types, lines_longer_than_80_chars
import 'package:universal_html/html.dart' as html;
// Fake interface for the logic that this package needs from (web-only) dart:ui.
// This is conditionally exported so the analyzer sees these methods as available.
typedef PlatroformViewFactory = html.Element Function(int viewId);
/// Shim for web_ui engine.PlatformViewRegistry
/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L62
class platformViewRegistry {
/// Shim for registerViewFactory
/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L72
static dynamic registerViewFactory(
String viewTypeId, PlatroformViewFactory viewFactory) {}
}
/// Shim for web_ui engine.AssetManager
/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L12
class webOnlyAssetManager {
static dynamic getAssetUrl(String asset) {}
}

@ -1,18 +1,18 @@
name: flutter_quill_extensions name: flutter_quill_extensions
description: Embed extensions for flutter_quill including image, video, formula and etc. description: Embed extensions for flutter_quill including image, video, formula and etc.
version: 0.2.1 version: 0.3.1
homepage: https://bulletjournal.us/home/index.html homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions
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:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_quill: ^7.1.7 flutter_quill: ^7.1.12
image_picker: ^0.8.5+3 image_picker: ^0.8.5+3
photo_view: ^0.14.0 photo_view: ^0.14.0
@ -21,6 +21,7 @@ dependencies:
gallery_saver: ^2.3.2 gallery_saver: ^2.3.2
math_keyboard: ^0.1.8 math_keyboard: ^0.1.8
string_validator: ^1.0.0 string_validator: ^1.0.0
universal_html: ^2.2.1
url_launcher: ^6.1.9 url_launcher: ^6.1.9
dev_dependencies: dev_dependencies:

@ -4,3 +4,4 @@ export 'src/models/documents/nodes/leaf.dart' hide Text;
export 'src/models/rules/insert.dart'; export 'src/models/rules/insert.dart';
export 'src/utils/platform.dart'; export 'src/utils/platform.dart';
export 'src/utils/string.dart'; export 'src/utils/string.dart';
export 'src/utils/widgets.dart';

@ -19,6 +19,8 @@ class Attribute<T> {
static final Map<String, Attribute> _registry = LinkedHashMap.of({ static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold, Attribute.bold.key: Attribute.bold,
Attribute.subscript.key: Attribute.subscript,
Attribute.superscript.key: Attribute.superscript,
Attribute.italic.key: Attribute.italic, Attribute.italic.key: Attribute.italic,
Attribute.small.key: Attribute.small, Attribute.small.key: Attribute.small,
Attribute.underline.key: Attribute.underline, Attribute.underline.key: Attribute.underline,
@ -42,10 +44,18 @@ 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();
static final ScriptAttribute subscript =
ScriptAttribute(ScriptAttributes.sub);
static final ScriptAttribute superscript =
ScriptAttribute(ScriptAttributes.sup);
static const ItalicAttribute italic = ItalicAttribute(); static const ItalicAttribute italic = ItalicAttribute();
static const SmallAttribute small = SmallAttribute(); static const SmallAttribute small = SmallAttribute();
@ -90,7 +100,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,8 +110,14 @@ 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.subscript.key,
Attribute.superscript.key,
Attribute.italic.key, Attribute.italic.key,
Attribute.small.key, Attribute.small.key,
Attribute.underline.key, Attribute.underline.key,
@ -138,6 +154,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);
@ -345,8 +366,26 @@ class TokenAttribute extends Attribute<String> {
const TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); const TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
} }
// `script` is supposed to be inline attribute but it is not supported yet class ScriptAttribute extends Attribute<String?> {
class ScriptAttribute extends Attribute<String> { ScriptAttribute(ScriptAttributes? val)
const ScriptAttribute(String val) : super('script', AttributeScope.INLINE, val?.value);
: super('script', AttributeScope.IGNORE, val); }
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);
} }

@ -170,6 +170,12 @@ class Document {
return (res.node as Line).collectAllStyles(res.offset, len); return (res.node as Line).collectAllStyles(res.offset, len);
} }
/// Returns all styles for any character within the specified text range.
List<OffsetValue<Style>> collectAllStylesWithOffset(int index, int len) {
final res = queryChild(index);
return (res.node as Line).collectAllStylesWithOffsets(res.offset, len);
}
/// Returns plain text within the specified text range. /// Returns plain text within the specified text range.
String getPlainText(int index, int len) { String getPlainText(int index, int len) {
final res = queryChild(index); final res = queryChild(index);

@ -458,6 +458,44 @@ class Line extends Container<Leaf?> {
return result; return result;
} }
/// Returns all styles for any character within the specified text range.
List<OffsetValue<Style>> collectAllStylesWithOffsets(
int offset,
int len, {
int beg = 0,
}) {
final local = math.min(length - offset, len);
final result = <OffsetValue<Style>>[];
final data = queryChild(offset, true);
var node = data.node as Leaf?;
if (node != null) {
var pos = 0;
pos = node.length - data.offset;
result.add(OffsetValue(node.documentOffset, node.style, node.length));
while (!node!.isLast && pos < local) {
node = node.next as Leaf;
result.add(OffsetValue(node.documentOffset, node.style, node.length));
pos += node.length;
}
}
result.add(OffsetValue(documentOffset, style, length));
if (parent is Block) {
final block = parent as Block;
result.add(OffsetValue(block.documentOffset, block.style, block.length));
}
final remaining = len - local;
if (remaining > 0 && nextLine != null) {
final rest =
nextLine!.collectAllStylesWithOffsets(0, remaining, beg: local);
result.addAll(rest);
}
return result;
}
/// Returns plain text within the specified text range. /// Returns plain text within the specified text range.
String getPlainText(int offset, int len) { String getPlainText(int offset, int len) {
final plainText = StringBuffer(); final plainText = StringBuffer();

@ -1,5 +1,6 @@
class OffsetValue<T> { class OffsetValue<T> {
OffsetValue(this.offset, this.value); OffsetValue(this.offset, this.value, [this.length]);
final int offset; final int offset;
final int? length;
final T value; final T value;
} }

@ -1,8 +1,21 @@
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.linkDialogPadding = const EdgeInsets.all(16),
this.mediaSelectorDialogConstraints,
this.mediaSelectorDialogPadding = const EdgeInsets.all(16),
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 +23,105 @@ 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;
/// The padding for content of [LinkStyleDialog].
final EdgeInsetsGeometry linkDialogPadding;
/// Constrains for [MediaSourceSelectorDialog].
final BoxConstraints? mediaSelectorDialogConstraints;
/// The padding for content of [MediaSourceSelectorDialog].
final EdgeInsetsGeometry mediaSelectorDialogPadding;
/// 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,
EdgeInsetsGeometry? linkDialogPadding,
BoxConstraints? imageDialogConstraints,
EdgeInsetsGeometry? mediaDialogPadding,
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,
linkDialogPadding: linkDialogPadding ?? this.linkDialogPadding,
mediaSelectorDialogConstraints:
imageDialogConstraints ?? mediaSelectorDialogConstraints,
mediaSelectorDialogPadding:
mediaDialogPadding ?? mediaSelectorDialogPadding,
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.linkDialogPadding == linkDialogPadding &&
other.mediaSelectorDialogConstraints ==
mediaSelectorDialogConstraints &&
other.mediaSelectorDialogPadding == mediaSelectorDialogPadding &&
other.isWrappable == isWrappable &&
other.runSpacing == runSpacing;
}
@override
int get hashCode => Object.hash(
labelTextStyle,
inputTextStyle,
dialogBackgroundColor,
shape,
buttonStyle,
linkDialogConstraints,
linkDialogPadding,
mediaSelectorDialogConstraints,
mediaSelectorDialogPadding,
isWrappable,
runSpacing,
);
} }

File diff suppressed because it is too large Load Diff

@ -101,6 +101,14 @@ class QuillController extends ChangeNotifier {
// Increases or decreases the indent of the current selection by 1. // Increases or decreases the indent of the current selection by 1.
void indentSelection(bool isIncrease) { void indentSelection(bool isIncrease) {
if (selection.isCollapsed) {
_indentSelectionFormat(isIncrease);
} else {
_indentSelectionEachLine(isIncrease);
}
}
void _indentSelectionFormat(bool isIncrease) {
final indent = getSelectionStyle().attributes[Attribute.indent.key]; final indent = getSelectionStyle().attributes[Attribute.indent.key];
if (indent == null) { if (indent == null) {
if (isIncrease) { if (isIncrease) {
@ -119,6 +127,38 @@ class QuillController extends ChangeNotifier {
formatSelection(Attribute.getIndentLevel(indent.value - 1)); formatSelection(Attribute.getIndentLevel(indent.value - 1));
} }
void _indentSelectionEachLine(bool isIncrease) {
final styles = document.collectAllStylesWithOffset(
selection.start,
selection.end - selection.start,
);
for (final style in styles) {
final indent = style.value.attributes[Attribute.indent.key];
final formatIndex = math.max(style.offset, selection.start);
final formatLength = math.min(
style.offset + (style.length ?? 0),
selection.end,
) -
style.offset;
Attribute? formatAttribute;
if (indent == null) {
if (isIncrease) {
formatAttribute = Attribute.indentL1;
}
} else if (indent.value == 1 && !isIncrease) {
formatAttribute = Attribute.clone(Attribute.indentL1, null);
} else if (isIncrease) {
formatAttribute = Attribute.getIndentLevel(indent.value + 1);
} else {
formatAttribute = Attribute.getIndentLevel(indent.value - 1);
}
if (formatAttribute != null) {
document.format(formatIndex, formatLength, formatAttribute);
}
}
notifyListeners();
}
/// Returns all styles for each node within selection /// Returns all styles for each node within selection
List<OffsetValue<Style>> getAllIndividualSelectionStyles() { List<OffsetValue<Style>> getAllIndividualSelectionStyles() {
final styles = document.collectAllIndividualStyles( final styles = document.collectAllIndividualStyles(

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
@ -141,6 +143,8 @@ class DefaultStyles {
this.h3, this.h3,
this.paragraph, this.paragraph,
this.bold, this.bold,
this.subscript,
this.superscript,
this.italic, this.italic,
this.small, this.small,
this.underline, this.underline,
@ -165,6 +169,8 @@ class DefaultStyles {
final DefaultTextBlockStyle? h3; final DefaultTextBlockStyle? h3;
final DefaultTextBlockStyle? paragraph; final DefaultTextBlockStyle? paragraph;
final TextStyle? bold; final TextStyle? bold;
final TextStyle? subscript;
final TextStyle? superscript;
final TextStyle? italic; final TextStyle? italic;
final TextStyle? small; final TextStyle? small;
final TextStyle? underline; final TextStyle? underline;
@ -244,6 +250,9 @@ class DefaultStyles {
paragraph: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0), paragraph: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0),
const VerticalSpacing(0, 0), null), const VerticalSpacing(0, 0), null),
bold: const TextStyle(fontWeight: FontWeight.bold), bold: const TextStyle(fontWeight: FontWeight.bold),
subscript: const TextStyle(fontFeatures: [FontFeature.subscripts()]),
superscript:
const TextStyle(fontFeatures: [FontFeature.superscripts()]),
italic: const TextStyle(fontStyle: FontStyle.italic), italic: const TextStyle(fontStyle: FontStyle.italic),
small: const TextStyle(fontSize: 12), small: const TextStyle(fontSize: 12),
underline: const TextStyle(decoration: TextDecoration.underline), underline: const TextStyle(decoration: TextDecoration.underline),
@ -317,6 +326,8 @@ class DefaultStyles {
h3: other.h3 ?? h3, h3: other.h3 ?? h3,
paragraph: other.paragraph ?? paragraph, paragraph: other.paragraph ?? paragraph,
bold: other.bold ?? bold, bold: other.bold ?? bold,
subscript: other.subscript ?? subscript,
superscript: other.superscript ?? superscript,
italic: other.italic ?? italic, italic: other.italic ?? italic,
small: other.small ?? small, small: other.small ?? small,
underline: other.underline ?? underline, underline: other.underline ?? underline,

@ -14,6 +14,9 @@ typedef EmbedsBuilder = EmbedBuilder Function(Embed node);
typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); typedef CustomStyleBuilder = TextStyle Function(Attribute attribute);
typedef CustomRecognizerBuilder = GestureRecognizer? Function(
Attribute attribute, Leaf leaf);
/// Delegate interface for the [EditorTextSelectionGestureDetectorBuilder]. /// Delegate interface for the [EditorTextSelectionGestureDetectorBuilder].
/// ///
/// The interface is usually implemented by textfield implementations wrapping /// The interface is usually implemented by textfield implementations wrapping

@ -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,
@ -175,6 +176,7 @@ class QuillEditor extends StatefulWidget {
this.unknownEmbedBuilder, this.unknownEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.customRecognizerBuilder,
this.locale, this.locale,
this.floatingCursorDisabled = false, this.floatingCursorDisabled = false,
this.textSelectionControls, this.textSelectionControls,
@ -184,8 +186,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 +305,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.
@ -369,6 +373,7 @@ class QuillEditor extends StatefulWidget {
final Iterable<EmbedBuilder>? embedBuilders; final Iterable<EmbedBuilder>? embedBuilders;
final EmbedBuilder? unknownEmbedBuilder; final EmbedBuilder? unknownEmbedBuilder;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CustomRecognizerBuilder? customRecognizerBuilder;
/// The locale to use for the editor toolbar, defaults to system locale /// The locale to use for the editor toolbar, defaults to system locale
/// More https://github.com/singerdmx/flutter-quill#translation /// More https://github.com/singerdmx/flutter-quill#translation
@ -401,7 +406,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 +424,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();
} }
@ -505,12 +520,14 @@ class QuillEditorState extends State<QuillEditor>
embedBuilder: _getEmbedBuilder, embedBuilder: _getEmbedBuilder,
linkActionPickerDelegate: widget.linkActionPickerDelegate, linkActionPickerDelegate: widget.linkActionPickerDelegate,
customStyleBuilder: widget.customStyleBuilder, customStyleBuilder: widget.customStyleBuilder,
customRecognizerBuilder: widget.customRecognizerBuilder,
floatingCursorDisabled: widget.floatingCursorDisabled, floatingCursorDisabled: widget.floatingCursorDisabled,
onImagePaste: widget.onImagePaste, onImagePaste: widget.onImagePaste,
customShortcuts: widget.customShortcuts, customShortcuts: widget.customShortcuts,
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,
@ -78,10 +79,12 @@ class RawEditor extends StatefulWidget {
this.scrollPhysics, this.scrollPhysics,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.customRecognizerBuilder,
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 +193,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,16 +249,27 @@ 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.
final EmbedsBuilder embedBuilder; final EmbedsBuilder embedBuilder;
final LinkActionPickerDelegate linkActionPickerDelegate; final LinkActionPickerDelegate linkActionPickerDelegate;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CustomRecognizerBuilder? customRecognizerBuilder;
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 +510,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,
@ -592,17 +677,16 @@ class RawEditorState extends EditorState
if (event is! RawKeyDownEvent) { if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
// Handle indenting blocks when pressing the tab key.
if (event.logicalKey == LogicalKeyboardKey.tab) {
return _handleTabKey(event);
}
// Don't handle key if there is an active selection. // Don't handle key if there is an active selection.
if (controller.selection.baseOffset != controller.selection.extentOffset) { if (controller.selection.baseOffset != controller.selection.extentOffset) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
// Handle indenting blocks when pressing the tab key.
if (event.logicalKey == LogicalKeyboardKey.tab) {
return _handleTabKey(event);
}
// Handle inserting lists when space is pressed following // Handle inserting lists when space is pressed following
// a list initiating phrase. // a list initiating phrase.
if (event.logicalKey == LogicalKeyboardKey.space) { if (event.logicalKey == LogicalKeyboardKey.space) {
@ -653,6 +737,19 @@ class RawEditorState extends EditorState
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (controller.selection.baseOffset != controller.selection.extentOffset) {
if (child.node == null || child.node!.parent == null) {
return KeyEventResult.handled;
}
final parentBlock = child.node!.parent!;
if (parentBlock.style.containsKey(Attribute.ol.key) ||
parentBlock.style.containsKey(Attribute.ul.key) ||
parentBlock.style.containsKey(Attribute.checked.key)) {
controller.indentSelection(!event.isShiftPressed);
}
return KeyEventResult.handled;
}
if (child.node == null) { if (child.node == null) {
return insertTabCharacter(); return insertTabCharacter();
} }
@ -830,6 +927,7 @@ class RawEditorState extends EditorState
textDirection: _textDirection, textDirection: _textDirection,
embedBuilder: widget.embedBuilder, embedBuilder: widget.embedBuilder,
customStyleBuilder: widget.customStyleBuilder, customStyleBuilder: widget.customStyleBuilder,
customRecognizerBuilder: widget.customRecognizerBuilder,
styles: _styles!, styles: _styles!,
readOnly: widget.readOnly, readOnly: widget.readOnly,
controller: controller, controller: controller,
@ -1570,11 +1668,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 +2590,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].
/// ///

@ -41,6 +41,7 @@ class TextLine extends StatefulWidget {
required this.linkActionPicker, required this.linkActionPicker,
this.textDirection, this.textDirection,
this.customStyleBuilder, this.customStyleBuilder,
this.customRecognizerBuilder,
this.customLinkPrefixes = const <String>[], this.customLinkPrefixes = const <String>[],
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -52,6 +53,7 @@ class TextLine extends StatefulWidget {
final bool readOnly; final bool readOnly;
final QuillController controller; final QuillController controller;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CustomRecognizerBuilder? customRecognizerBuilder;
final ValueChanged<String>? onLaunchUrl; final ValueChanged<String>? onLaunchUrl;
final LinkActionPicker linkActionPicker; final LinkActionPicker linkActionPicker;
final List<String> customLinkPrefixes; final List<String> customLinkPrefixes;
@ -313,12 +315,14 @@ class _TextLineState extends State<TextLine> {
final isLink = nodeStyle.containsKey(Attribute.link.key) && final isLink = nodeStyle.containsKey(Attribute.link.key) &&
nodeStyle.attributes[Attribute.link.key]!.value != null; nodeStyle.attributes[Attribute.link.key]!.value != null;
final recognizer = _getRecognizer(node, isLink);
return TextSpan( return TextSpan(
text: textNode.value, text: textNode.value,
style: _getInlineTextStyle( style: _getInlineTextStyle(
textNode, defaultStyles, nodeStyle, lineStyle, isLink), textNode, defaultStyles, nodeStyle, lineStyle, isLink),
recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null, recognizer: recognizer,
mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null, mouseCursor: (recognizer != null) ? SystemMouseCursors.click : null,
); );
} }
@ -352,6 +356,14 @@ class _TextLineState extends State<TextLine> {
} }
}); });
if (nodeStyle.containsKey(Attribute.script.key)) {
if (nodeStyle.attributes.values.contains(Attribute.subscript)) {
res = _merge(res, defaultStyles.subscript!);
} else if (nodeStyle.attributes.values.contains(Attribute.superscript)) {
res = _merge(res, defaultStyles.superscript!);
}
}
if (nodeStyle.containsKey(Attribute.inlineCode.key)) { if (nodeStyle.containsKey(Attribute.inlineCode.key)) {
res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle)); res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle));
} }
@ -398,11 +410,29 @@ class _TextLineState extends State<TextLine> {
return res; return res;
} }
GestureRecognizer _getRecognizer(Node segment) { GestureRecognizer? _getRecognizer(Node segment, bool isLink) {
if (_linkRecognizers.containsKey(segment)) { if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!; return _linkRecognizers[segment]!;
} }
if (widget.customRecognizerBuilder != null) {
final textNode = segment as leaf.Text;
final nodeStyle = textNode.style;
nodeStyle.attributes.forEach((key, value) {
final recognizer = widget.customRecognizerBuilder!.call(value, segment);
if (recognizer != null) {
_linkRecognizers[segment] = recognizer;
return;
}
});
}
if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!;
}
if (isLink && canLaunchLinks) {
if (isDesktop() || widget.readOnly) { if (isDesktop() || widget.readOnly) {
_linkRecognizers[segment] = TapGestureRecognizer() _linkRecognizers[segment] = TapGestureRecognizer()
..onTap = () => _tapNodeLink(segment); ..onTap = () => _tapNodeLink(segment);
@ -410,7 +440,8 @@ class _TextLineState extends State<TextLine> {
_linkRecognizers[segment] = LongPressGestureRecognizer() _linkRecognizers[segment] = LongPressGestureRecognizer()
..onLongPress = () => _longPressLink(segment); ..onLongPress = () => _longPressLink(segment);
} }
return _linkRecognizers[segment]!; }
return _linkRecognizers[segment];
} }
Future<void> _launchUrl(String url) async { Future<void> _launchUrl(String url) async {

@ -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';
@ -102,6 +103,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
bool showRedo = true, bool showRedo = true,
bool showDirection = false, bool showDirection = false,
bool showSearchButton = true, bool showSearchButton = true,
bool showSubscript = true,
bool showSuperscript = true,
List<QuillCustomButton> customButtons = const [], List<QuillCustomButton> customButtons = const [],
///Map of font sizes in string ///Map of font sizes in string
@ -207,6 +210,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
ToolbarButtons.fontFamily: 'Font family'.i18n, ToolbarButtons.fontFamily: 'Font family'.i18n,
ToolbarButtons.fontSize: 'Font size'.i18n, ToolbarButtons.fontSize: 'Font size'.i18n,
ToolbarButtons.bold: 'Bold'.i18n, ToolbarButtons.bold: 'Bold'.i18n,
ToolbarButtons.subscript: 'Subscript'.i18n,
ToolbarButtons.superscript: 'Superscript'.i18n,
ToolbarButtons.italic: 'Italic'.i18n, ToolbarButtons.italic: 'Italic'.i18n,
ToolbarButtons.small: 'Small'.i18n, ToolbarButtons.small: 'Small'.i18n,
ToolbarButtons.underline: 'Underline'.i18n, ToolbarButtons.underline: 'Underline'.i18n,
@ -295,6 +300,26 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
), ),
if (showSubscript)
ToggleStyleButton(
attribute: Attribute.subscript,
icon: Icons.subscript,
iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.subscript],
controller: controller,
iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed,
),
if (showSuperscript)
ToggleStyleButton(
attribute: Attribute.superscript,
icon: Icons.superscript,
iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.superscript],
controller: controller,
iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed,
),
if (showItalicButton) if (showItalicButton)
ToggleStyleButton( ToggleStyleButton(
attribute: Attribute.italic, attribute: Attribute.italic,

@ -4,6 +4,8 @@ enum ToolbarButtons {
fontFamily, fontFamily,
fontSize, fontSize,
bold, bold,
subscript,
superscript,
italic, italic,
small, small,
underline, underline,

@ -0,0 +1,446 @@
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 '../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));
}
}

@ -105,7 +105,8 @@ class _ToggleStyleButtonState extends State<ToggleStyleButton> {
} }
bool _getIsToggled(Map<String, Attribute> attrs) { bool _getIsToggled(Map<String, Attribute> attrs) {
if (widget.attribute.key == Attribute.list.key) { if (widget.attribute.key == Attribute.list.key ||
widget.attribute.key == Attribute.script.key) {
final attribute = attrs[widget.attribute.key]; final attribute = attrs[widget.attribute.key];
if (attribute == null) { if (attribute == null) {
return false; return false;

@ -1,12 +1,12 @@
name: flutter_quill name: flutter_quill
description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us)
version: 7.1.8 version: 7.1.14
#author: bulletjournal #author: bulletjournal
homepage: https://bulletjournal.us/home/index.html 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