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. 118
      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. 105
      lib/src/widgets/editor.dart
  22. 351
      lib/src/widgets/raw_editor.dart
  23. 51
      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,58 +49,58 @@ 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,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: imageButtonTooltip, tooltip: imageButtonTooltip,
controller: controller, controller: controller,
onImagePickCallback: onImagePickCallback, onImagePickCallback: onImagePickCallback,
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl, webImagePickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector, mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme, iconTheme: iconTheme,
dialogTheme: dialogTheme, dialogTheme: dialogTheme,
), ),
if (showVideoButton) if (showVideoButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( (controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton(
icon: Icons.movie_creation, icon: Icons.movie_creation,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: videoButtonTooltip, tooltip: videoButtonTooltip,
controller: controller, controller: controller,
onVideoPickCallback: onVideoPickCallback, onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
webVideoPickImpl: webImagePickImpl, webVideoPickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector, mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme, iconTheme: iconTheme,
dialogTheme: dialogTheme, dialogTheme: dialogTheme,
), ),
if ((onImagePickCallback != null || onVideoPickCallback != null) && if ((onImagePickCallback != null || onVideoPickCallback != null) &&
showCameraButton) showCameraButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton(
icon: Icons.photo_camera, icon: Icons.photo_camera,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: cameraButtonTooltip, tooltip: cameraButtonTooltip,
controller: controller, controller: controller,
onImagePickCallback: onImagePickCallback, onImagePickCallback: onImagePickCallback,
onVideoPickCallback: onVideoPickCallback, onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl, webImagePickImpl: webImagePickImpl,
webVideoPickImpl: webVideoPickImpl, webVideoPickImpl: webVideoPickImpl,
cameraPickSettingSelector: cameraPickSettingSelector, cameraPickSettingSelector: cameraPickSettingSelector,
iconTheme: iconTheme, iconTheme: iconTheme,
), ),
if (showFormulaButton) if (showFormulaButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => FormulaButton( (controller, toolbarIconSize, iconTheme, dialogTheme) =>
icon: Icons.functions, FormulaButton(
iconSize: toolbarIconSize, icon: Icons.functions,
tooltip: formulaButtonTooltip, iconSize: toolbarIconSize,
controller: controller, tooltip: formulaButtonTooltip,
iconTheme: iconTheme, controller: controller,
dialogTheme: dialogTheme, iconTheme: iconTheme,
) dialogTheme: dialogTheme,
]; )
} ];
} }

@ -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,49 +144,51 @@ 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.customRecognizerBuilder,
this.floatingCursorDisabled = false, this.locale,
this.textSelectionControls, this.floatingCursorDisabled = false,
this.onImagePaste, this.textSelectionControls,
this.customShortcuts, this.onImagePaste,
this.customActions, this.customShortcuts,
this.detectWordBoundary = true, this.customActions,
this.enableUnfocusOnTapOutside = true, this.detectWordBoundary = true,
this.customLinkPrefixes = const <String>[], this.enableUnfocusOnTapOutside = true,
Key? key}) this.customLinkPrefixes = const <String>[],
: super(key: key); this.dialogTheme,
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,46 +42,49 @@ 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.customRecognizerBuilder,
this.onImagePaste, this.floatingCursorDisabled = false,
this.customLinkPrefixes = const <String>[]}) this.onImagePaste,
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), this.customLinkPrefixes = const <String>[],
this.dialogTheme,
}) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), assert(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,19 +410,38 @@ 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 (isDesktop() || widget.readOnly) { if (widget.customRecognizerBuilder != null) {
_linkRecognizers[segment] = TapGestureRecognizer() final textNode = segment as leaf.Text;
..onTap = () => _tapNodeLink(segment); final nodeStyle = textNode.style;
} else {
_linkRecognizers[segment] = LongPressGestureRecognizer() nodeStyle.attributes.forEach((key, value) {
..onLongPress = () => _longPressLink(segment); 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) {
_linkRecognizers[segment] = TapGestureRecognizer()
..onTap = () => _tapNodeLink(segment);
} else {
_linkRecognizers[segment] = LongPressGestureRecognizer()
..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