commit
dc01bc6ab7
91 changed files with 6926 additions and 1644 deletions
@ -0,0 +1,23 @@ |
||||
name: flutter-quill CI |
||||
|
||||
on: |
||||
push: |
||||
branches: [master] |
||||
pull_request: |
||||
branches: [master] |
||||
|
||||
jobs: |
||||
tests: |
||||
runs-on: ubuntu-latest |
||||
|
||||
steps: |
||||
- uses: actions/checkout@v3 |
||||
- uses: subosito/flutter-action@v2 |
||||
with: |
||||
channel: 'stable' |
||||
- run: flutter --version |
||||
- run: flutter pub get |
||||
- run: flutter pub get -C flutter_quill_extensions |
||||
- run: flutter analyze |
||||
- run: flutter test |
||||
- run: flutter pub publish --dry-run |
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,26 @@ |
||||
## 0.3.3 |
||||
* Fix a prototype bug which was bring by [PR #1230](https://github.com/singerdmx/flutter-quill/pull/1230#issuecomment-1560597099) |
||||
|
||||
## 0.3.2 |
||||
* Updated dependencies to support intl 0.18 |
||||
|
||||
## 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 |
||||
|
||||
* Allow widgets to override widget span properties [b7951b0](https://github.com/singerdmx/flutter-quill/commit/b7951b02c9086ea42e7aad6d78e6c9b0297562e5) |
||||
* Remove tuples [3e9452e](https://github.com/singerdmx/flutter-quill/commit/3e9452e675e8734ff50364c5f7b5d34088d5ff05) |
||||
* Remove transparent color of ImageVideoUtils dialog [74544bd](https://github.com/singerdmx/flutter-quill/commit/74544bd945a9d212ca1e8d6b3053dbecee22b720) |
||||
* Migrate to `youtube_player_flutter` from `youtube_player_flutter_quill` |
||||
* Updates to forumla button [5228f38](https://github.com/singerdmx/flutter-quill/commit/5228f389ba6f37d61d445cfe138c19fcf8766d71) |
||||
|
||||
## 0.1.0 |
||||
|
||||
* Initial release |
||||
|
@ -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; |
||||
} |
@ -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) {} |
||||
} |
@ -0,0 +1 @@ |
||||
export 'dart:ui'; |
@ -0,0 +1,3 @@ |
||||
library flutter_quill_test; |
||||
|
||||
export 'src/test/widget_tester_extension.dart'; |
@ -0,0 +1,19 @@ |
||||
import '../documents/document.dart'; |
||||
import '../quill_delta.dart'; |
||||
|
||||
class DocChange { |
||||
DocChange( |
||||
this.before, |
||||
this.change, |
||||
this.source, |
||||
); |
||||
|
||||
/// Document state before [change]. |
||||
final Delta before; |
||||
|
||||
/// Change delta applied to the document. |
||||
final Delta change; |
||||
|
||||
/// The source of this change. |
||||
final ChangeSource source; |
||||
} |
@ -0,0 +1,9 @@ |
||||
class HistoryChanged { |
||||
const HistoryChanged( |
||||
this.changed, |
||||
this.len, |
||||
); |
||||
|
||||
final bool changed; |
||||
final int? len; |
||||
} |
@ -0,0 +1,9 @@ |
||||
class ImageUrl { |
||||
const ImageUrl( |
||||
this.url, |
||||
this.styleString, |
||||
); |
||||
|
||||
final String url; |
||||
final String styleString; |
||||
} |
@ -0,0 +1,6 @@ |
||||
class OffsetValue<T> { |
||||
OffsetValue(this.offset, this.value, [this.length]); |
||||
final int offset; |
||||
final int? length; |
||||
final T value; |
||||
} |
@ -0,0 +1,14 @@ |
||||
class OptionalSize { |
||||
OptionalSize( |
||||
this.width, |
||||
this.height, |
||||
); |
||||
|
||||
/// If non-null, requires the child to have exactly this width. |
||||
/// If null, the child is free to choose its own width. |
||||
final double? width; |
||||
|
||||
/// If non-null, requires the child to have exactly this height. |
||||
/// If null, the child is free to choose its own height. |
||||
final double? height; |
||||
} |
@ -0,0 +1,9 @@ |
||||
import '../documents/nodes/leaf.dart'; |
||||
import '../documents/nodes/line.dart'; |
||||
|
||||
class SegmentLeafNode { |
||||
const SegmentLeafNode(this.line, this.leaf); |
||||
|
||||
final Line? line; |
||||
final Leaf? leaf; |
||||
} |
@ -0,0 +1,9 @@ |
||||
class VerticalSpacing { |
||||
const VerticalSpacing( |
||||
this.top, |
||||
this.bottom, |
||||
); |
||||
|
||||
final double top; |
||||
final double bottom; |
||||
} |
@ -1,14 +1,26 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class QuillCustomButton { |
||||
const QuillCustomButton({this.icon, this.onTap, this.child}); |
||||
const QuillCustomButton({ |
||||
this.icon, |
||||
this.iconColor, |
||||
this.onTap, |
||||
this.tooltip, |
||||
this.child, |
||||
}); |
||||
|
||||
///The icon widget |
||||
final IconData? icon; |
||||
|
||||
///The icon color; |
||||
final Color? iconColor; |
||||
|
||||
///The function when the icon is tapped |
||||
final VoidCallback? onTap; |
||||
|
||||
///The customButton placeholder |
||||
final Widget? child; |
||||
|
||||
/// The button tooltip. |
||||
final String? tooltip; |
||||
} |
||||
|
@ -0,0 +1,60 @@ |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_test/flutter_test.dart'; |
||||
|
||||
import '../widgets/editor.dart'; |
||||
import '../widgets/raw_editor.dart'; |
||||
|
||||
/// Extends |
||||
extension QuillEnterText on WidgetTester { |
||||
/// Give the QuillEditor widget specified by [finder] the focus. |
||||
Future<void> quillGiveFocus(Finder finder) { |
||||
return TestAsyncUtils.guard(() async { |
||||
final editor = state<QuillEditorState>( |
||||
find.descendant( |
||||
of: finder, |
||||
matching: |
||||
find.byType(QuillEditor, skipOffstage: finder.skipOffstage), |
||||
matchRoot: true), |
||||
); |
||||
editor.widget.focusNode.requestFocus(); |
||||
await pump(); |
||||
expect(editor.widget.focusNode.hasFocus, isTrue); |
||||
}); |
||||
} |
||||
|
||||
/// Give the QuillEditor widget specified by [finder] the focus and update its |
||||
/// editing value with [text], as if it had been provided by the onscreen |
||||
/// keyboard. |
||||
/// |
||||
/// The widget specified by [finder] must be a [QuillEditor] or have a |
||||
/// [QuillEditor] descendant. For example `find.byType(QuillEditor)`. |
||||
Future<void> quillEnterText(Finder finder, String text) async { |
||||
return TestAsyncUtils.guard(() async { |
||||
await quillGiveFocus(finder); |
||||
await quillUpdateEditingValue(finder, text); |
||||
await idle(); |
||||
}); |
||||
} |
||||
|
||||
/// Update the text editing value of the QuillEditor widget specified by |
||||
/// [finder] with [text], as if it had been provided by the onscreen keyboard. |
||||
/// |
||||
/// The widget specified by [finder] must already have focus and be a |
||||
/// [QuillEditor] or have a [QuillEditor] descendant. For example |
||||
/// `find.byType(QuillEditor)`. |
||||
Future<void> quillUpdateEditingValue(Finder finder, String text) async { |
||||
return TestAsyncUtils.guard(() async { |
||||
final editor = state<RawEditorState>( |
||||
find.descendant( |
||||
of: finder, |
||||
matching: find.byType(RawEditor, skipOffstage: finder.skipOffstage), |
||||
matchRoot: true), |
||||
); |
||||
testTextInput.updateEditingValue(TextEditingValue( |
||||
text: text, |
||||
selection: TextSelection.collapsed( |
||||
offset: editor.textEditingValue.text.length))); |
||||
await idle(); |
||||
}); |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@ |
||||
T? castOrNull<T>(dynamic x) => x is T ? x : null; |
@ -0,0 +1,21 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
typedef WidgetWrapper = Widget Function(Widget child); |
||||
|
||||
/// Provides utiulity widgets. |
||||
abstract class UtilityWidgets { |
||||
/// Conditionally wraps the [child] with [Tooltip] widget if [message] |
||||
/// is not null and not empty. |
||||
static Widget maybeTooltip({required Widget child, String? message}) => |
||||
(message ?? '').isNotEmpty |
||||
? Tooltip(message: message!, child: child) |
||||
: child; |
||||
|
||||
/// Conditionally wraps the [child] with [wrapper] widget if [enabled] |
||||
/// is true. |
||||
static Widget maybeWidget( |
||||
{required WidgetWrapper wrapper, |
||||
required Widget child, |
||||
bool enabled = false}) => |
||||
enabled ? wrapper(child) : child; |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,43 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../models/themes/quill_icon_theme.dart'; |
||||
import '../toolbar.dart'; |
||||
|
||||
class CustomButton extends StatelessWidget { |
||||
const CustomButton({ |
||||
required this.onPressed, |
||||
required this.icon, |
||||
this.iconColor, |
||||
this.iconSize = kDefaultIconSize, |
||||
this.iconTheme, |
||||
this.afterButtonPressed, |
||||
this.tooltip, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final VoidCallback? onPressed; |
||||
final IconData? icon; |
||||
final Color? iconColor; |
||||
final double iconSize; |
||||
final QuillIconTheme? iconTheme; |
||||
final VoidCallback? afterButtonPressed; |
||||
final String? tooltip; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
|
||||
final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: iconSize * kIconButtonFactor, |
||||
icon: Icon(icon, size: iconSize, color: iconColor), |
||||
tooltip: tooltip, |
||||
borderRadius: iconTheme?.borderRadius ?? 2, |
||||
onPressed: onPressed, |
||||
afterPressed: afterButtonPressed, |
||||
fillColor: iconTheme?.iconUnselectedFillColor ?? theme.canvasColor, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,32 @@ |
||||
enum ToolbarButtons { |
||||
undo, |
||||
redo, |
||||
fontFamily, |
||||
fontSize, |
||||
bold, |
||||
subscript, |
||||
superscript, |
||||
italic, |
||||
small, |
||||
underline, |
||||
strikeThrough, |
||||
inlineCode, |
||||
color, |
||||
backgroundColor, |
||||
clearFormat, |
||||
centerAlignment, |
||||
leftAlignment, |
||||
rightAlignment, |
||||
justifyAlignment, |
||||
direction, |
||||
headerStyle, |
||||
listNumbers, |
||||
listBullets, |
||||
listChecks, |
||||
codeBlock, |
||||
quote, |
||||
indentIncrease, |
||||
indentDecrease, |
||||
link, |
||||
search, |
||||
} |
@ -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)); |
||||
} |
||||
} |
@ -0,0 +1,136 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../../translations.dart'; |
||||
import '../../models/documents/document.dart'; |
||||
import '../../models/themes/quill_dialog_theme.dart'; |
||||
import '../controller.dart'; |
||||
|
||||
class SearchDialog extends StatefulWidget { |
||||
const SearchDialog( |
||||
{required this.controller, this.dialogTheme, this.text, Key? key}) |
||||
: super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final QuillDialogTheme? dialogTheme; |
||||
final String? text; |
||||
|
||||
@override |
||||
_SearchDialogState createState() => _SearchDialogState(); |
||||
} |
||||
|
||||
class _SearchDialogState extends State<SearchDialog> { |
||||
late String _text; |
||||
late TextEditingController _controller; |
||||
late List<int>? _offsets; |
||||
late int _index; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_text = widget.text ?? ''; |
||||
_offsets = null; |
||||
_index = 0; |
||||
_controller = TextEditingController(text: _text); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return StatefulBuilder(builder: (context, setState) { |
||||
var label = ''; |
||||
if (_offsets != null) { |
||||
label = '${_offsets!.length} ${'matches'.i18n}'; |
||||
if (_offsets!.isNotEmpty) { |
||||
label += ', ${'showing match'.i18n} ${_index + 1}'; |
||||
} |
||||
} |
||||
return AlertDialog( |
||||
backgroundColor: widget.dialogTheme?.dialogBackgroundColor, |
||||
content: Container( |
||||
height: 100, |
||||
child: Column( |
||||
children: [ |
||||
TextField( |
||||
keyboardType: TextInputType.multiline, |
||||
style: widget.dialogTheme?.inputTextStyle, |
||||
decoration: InputDecoration( |
||||
labelText: 'Search'.i18n, |
||||
labelStyle: widget.dialogTheme?.labelTextStyle, |
||||
floatingLabelStyle: widget.dialogTheme?.labelTextStyle), |
||||
autofocus: true, |
||||
onChanged: _textChanged, |
||||
controller: _controller, |
||||
), |
||||
if (_offsets != null) |
||||
Padding( |
||||
padding: const EdgeInsets.all(8), |
||||
child: Text(label, textAlign: TextAlign.left), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
actions: [ |
||||
if (_offsets != null && _offsets!.isNotEmpty && _index > 0) |
||||
TextButton( |
||||
onPressed: () { |
||||
setState(() { |
||||
_index -= 1; |
||||
}); |
||||
_moveToPosition(); |
||||
}, |
||||
child: Text( |
||||
'Prev'.i18n, |
||||
style: widget.dialogTheme?.labelTextStyle, |
||||
), |
||||
), |
||||
if (_offsets != null && |
||||
_offsets!.isNotEmpty && |
||||
_index < _offsets!.length - 1) |
||||
TextButton( |
||||
onPressed: () { |
||||
setState(() { |
||||
_index += 1; |
||||
}); |
||||
_moveToPosition(); |
||||
}, |
||||
child: Text( |
||||
'Next'.i18n, |
||||
style: widget.dialogTheme?.labelTextStyle, |
||||
), |
||||
), |
||||
if (_offsets == null && _text.isNotEmpty) |
||||
TextButton( |
||||
onPressed: () { |
||||
setState(() { |
||||
_offsets = widget.controller.document.search(_text); |
||||
_index = 0; |
||||
}); |
||||
if (_offsets!.isNotEmpty) { |
||||
_moveToPosition(); |
||||
} |
||||
}, |
||||
child: Text( |
||||
'Ok'.i18n, |
||||
style: widget.dialogTheme?.labelTextStyle, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
}); |
||||
} |
||||
|
||||
void _moveToPosition() { |
||||
widget.controller.updateSelection( |
||||
TextSelection( |
||||
baseOffset: _offsets![_index], |
||||
extentOffset: _offsets![_index] + _text.length), |
||||
ChangeSource.LOCAL); |
||||
} |
||||
|
||||
void _textChanged(String value) { |
||||
setState(() { |
||||
_text = value; |
||||
_offsets = null; |
||||
_index = 0; |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,95 @@ |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
import 'package:flutter_quill/flutter_quill_test.dart'; |
||||
import 'package:flutter_test/flutter_test.dart'; |
||||
|
||||
void main() { |
||||
group('Bug fix', () { |
||||
group( |
||||
'1266 - QuillToolbar.basic() custom buttons do not have correct fill' |
||||
'color set', () { |
||||
testWidgets('fillColor of custom buttons and builtin buttons match', |
||||
(tester) async { |
||||
const tooltip = 'custom button'; |
||||
|
||||
await tester.pumpWidget(MaterialApp( |
||||
home: QuillToolbar.basic( |
||||
showRedo: false, |
||||
controller: QuillController.basic(), |
||||
customButtons: [const QuillCustomButton(tooltip: tooltip)], |
||||
))); |
||||
|
||||
final builtinFinder = find.descendant( |
||||
of: find.byType(HistoryButton), |
||||
matching: find.byType(QuillIconButton), |
||||
matchRoot: true); |
||||
expect(builtinFinder, findsOneWidget); |
||||
final builtinButton = |
||||
builtinFinder.evaluate().first.widget as QuillIconButton; |
||||
|
||||
final customFinder = find.descendant( |
||||
of: find.byType(QuillToolbar), |
||||
matching: find.byWidgetPredicate((widget) => |
||||
widget is QuillIconButton && widget.tooltip == tooltip), |
||||
matchRoot: true); |
||||
expect(customFinder, findsOneWidget); |
||||
final customButton = |
||||
customFinder.evaluate().first.widget as QuillIconButton; |
||||
|
||||
expect(customButton.fillColor, equals(builtinButton.fillColor)); |
||||
}); |
||||
}); |
||||
|
||||
group('1189 - The provided text position is not in the current node', () { |
||||
late QuillController controller; |
||||
late QuillEditor editor; |
||||
|
||||
setUp(() { |
||||
controller = QuillController.basic(); |
||||
editor = QuillEditor.basic(controller: controller, readOnly: false); |
||||
}); |
||||
|
||||
tearDown(() { |
||||
controller.dispose(); |
||||
}); |
||||
|
||||
testWidgets('Refocus editor after controller clears document', |
||||
(tester) async { |
||||
await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); |
||||
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||
|
||||
editor.focusNode.unfocus(); |
||||
await tester.pump(); |
||||
controller.clear(); |
||||
editor.focusNode.requestFocus(); |
||||
await tester.pump(); |
||||
expect(tester.takeException(), isNull); |
||||
}); |
||||
|
||||
testWidgets('Refocus editor after removing block attribute', |
||||
(tester) async { |
||||
await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); |
||||
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||
|
||||
controller.formatSelection(Attribute.ul); |
||||
editor.focusNode.unfocus(); |
||||
await tester.pump(); |
||||
controller.formatSelection(const ListAttribute(null)); |
||||
editor.focusNode.requestFocus(); |
||||
await tester.pump(); |
||||
expect(tester.takeException(), isNull); |
||||
}); |
||||
|
||||
testWidgets('Tap checkbox in unfocused editor', (tester) async { |
||||
await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); |
||||
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||
|
||||
controller.formatSelection(Attribute.unchecked); |
||||
editor.focusNode.unfocus(); |
||||
await tester.pump(); |
||||
await tester.tap(find.byType(CheckboxPoint)); |
||||
expect(tester.takeException(), isNull); |
||||
}); |
||||
}); |
||||
}); |
||||
} |
@ -0,0 +1,290 @@ |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
import 'package:flutter_test/flutter_test.dart'; |
||||
|
||||
void main() { |
||||
const testDocumentContents = 'data'; |
||||
late QuillController controller; |
||||
|
||||
setUp(() { |
||||
controller = QuillController.basic() |
||||
..compose(Delta()..insert(testDocumentContents), |
||||
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); |
||||
}); |
||||
|
||||
group('controller', () { |
||||
test('set document', () { |
||||
const replacementContents = 'replacement\n'; |
||||
final newDocument = |
||||
Document.fromDelta(Delta()..insert(replacementContents)); |
||||
var listenerCalled = false; |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..document = newDocument; |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.toPlainText(), replacementContents); |
||||
}); |
||||
|
||||
test('getSelectionStyle', () { |
||||
controller |
||||
..formatText(0, 5, Attribute.h1) |
||||
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), |
||||
ChangeSource.LOCAL); |
||||
|
||||
expect(controller.getSelectionStyle().values, [Attribute.h1]); |
||||
}); |
||||
|
||||
test('indentSelection with single line document', () { |
||||
var listenerCalled = false; |
||||
// With selection range |
||||
controller |
||||
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), |
||||
ChangeSource.LOCAL) |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..indentSelection(true); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||
controller.indentSelection(true); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL2]); |
||||
controller.indentSelection(false); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||
controller.indentSelection(false); |
||||
expect(controller.getSelectionStyle().values, []); |
||||
|
||||
// With collapsed selection |
||||
controller |
||||
..updateSelection( |
||||
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||
..indentSelection(true); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||
controller |
||||
..updateSelection( |
||||
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||
..indentSelection(true); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL2]); |
||||
controller.indentSelection(false); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||
controller.indentSelection(false); |
||||
expect(controller.getSelectionStyle().values, []); |
||||
}); |
||||
|
||||
test('indentSelection with multiline document', () { |
||||
controller |
||||
..compose(Delta()..insert('line1\nline2\nline3\n'), |
||||
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||
// Indent first line |
||||
..updateSelection( |
||||
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) |
||||
..indentSelection(true); |
||||
expect(controller.getSelectionStyle().values, [Attribute.indentL1]); |
||||
|
||||
// Indent first two lines |
||||
controller |
||||
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 11), |
||||
ChangeSource.LOCAL) |
||||
..indentSelection(true); |
||||
|
||||
// Should have both L1 and L2 indent attributes in selection. |
||||
expect(controller.getAllSelectionStyles(), |
||||
contains(Style().put(Attribute.indentL1).put(Attribute.indentL2))); |
||||
|
||||
// Remaining lines should have no attributes. |
||||
controller.updateSelection( |
||||
TextSelection( |
||||
baseOffset: 12, |
||||
extentOffset: controller.document.toPlainText().length - 1), |
||||
ChangeSource.LOCAL); |
||||
expect(controller.getAllSelectionStyles(), everyElement(Style())); |
||||
}); |
||||
|
||||
test('getAllIndividualSelectionStyles', () { |
||||
controller.formatText(0, 2, Attribute.bold); |
||||
final result = controller.getAllIndividualSelectionStyles(); |
||||
expect(result.length, 1); |
||||
expect(result[0].offset, 0); |
||||
expect(result[0].value, Style().put(Attribute.bold)); |
||||
}); |
||||
|
||||
test('getPlainText', () { |
||||
controller.updateSelection( |
||||
const TextSelection(baseOffset: 0, extentOffset: 4), |
||||
ChangeSource.LOCAL); |
||||
|
||||
expect(controller.getPlainText(), testDocumentContents); |
||||
}); |
||||
|
||||
test('getAllSelectionStyles', () { |
||||
controller.formatText(0, 2, Attribute.bold); |
||||
expect(controller.getAllSelectionStyles(), |
||||
contains(Style().put(Attribute.bold))); |
||||
}); |
||||
|
||||
test('undo', () { |
||||
var listenerCalled = false; |
||||
controller.updateSelection( |
||||
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL); |
||||
|
||||
expect(controller.document.toDelta(), Delta()..insert('data\n')); |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..undo(); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.toDelta(), Delta()..insert('\n')); |
||||
}); |
||||
|
||||
test('redo', () { |
||||
var listenerCalled = false; |
||||
controller.updateSelection( |
||||
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL); |
||||
|
||||
expect(controller.document.toDelta(), Delta()..insert('data\n')); |
||||
controller.undo(); |
||||
expect(controller.document.toDelta(), Delta()..insert('\n')); |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..redo(); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.toDelta(), Delta()..insert('data\n')); |
||||
}); |
||||
test('clear', () { |
||||
var listenerCalled = false; |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..clear(); |
||||
|
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.toDelta(), Delta()..insert('\n')); |
||||
}); |
||||
|
||||
test('replaceText', () { |
||||
var listenerCalled = false; |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..replaceText(1, 2, '11', const TextSelection.collapsed(offset: 0)); |
||||
|
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.toDelta(), Delta()..insert('d11a\n')); |
||||
}); |
||||
|
||||
test('formatTextStyle', () { |
||||
var listenerCalled = false; |
||||
final style = Style().put(Attribute.bold).put(Attribute.italic); |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..formatTextStyle(0, 2, style); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.collectAllStyles(0, 2), contains(style)); |
||||
expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); |
||||
}); |
||||
|
||||
test('formatText', () { |
||||
var listenerCalled = false; |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..formatText(0, 2, Attribute.bold); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.collectAllStyles(0, 2), |
||||
contains(Style().put(Attribute.bold))); |
||||
expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); |
||||
}); |
||||
|
||||
test('formatSelection', () { |
||||
var listenerCalled = false; |
||||
controller |
||||
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 2), |
||||
ChangeSource.LOCAL) |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..formatSelection(Attribute.bold); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.collectAllStyles(0, 2), |
||||
contains(Style().put(Attribute.bold))); |
||||
expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); |
||||
}); |
||||
|
||||
test('moveCursorToStart', () { |
||||
var listenerCalled = false; |
||||
controller |
||||
..updateSelection( |
||||
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL) |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}); |
||||
expect(controller.selection, const TextSelection.collapsed(offset: 4)); |
||||
|
||||
controller.moveCursorToStart(); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
||||
}); |
||||
|
||||
test('moveCursorToPosition', () { |
||||
var listenerCalled = false; |
||||
controller.addListener(() { |
||||
listenerCalled = true; |
||||
}); |
||||
expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
||||
|
||||
controller.moveCursorToPosition(2); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.selection, const TextSelection.collapsed(offset: 2)); |
||||
}); |
||||
|
||||
test('moveCursorToEnd', () { |
||||
var listenerCalled = false; |
||||
controller.addListener(() { |
||||
listenerCalled = true; |
||||
}); |
||||
expect(controller.selection, const TextSelection.collapsed(offset: 0)); |
||||
|
||||
controller.moveCursorToEnd(); |
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.selection, |
||||
TextSelection.collapsed(offset: controller.document.length - 1)); |
||||
}); |
||||
|
||||
test('updateSelection', () { |
||||
var listenerCalled = false; |
||||
const selection = TextSelection.collapsed(offset: 0); |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..updateSelection(selection, ChangeSource.LOCAL); |
||||
|
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.selection, selection); |
||||
}); |
||||
|
||||
test('compose', () { |
||||
var listenerCalled = false; |
||||
final originalContents = controller.document.toPlainText(); |
||||
controller |
||||
..addListener(() { |
||||
listenerCalled = true; |
||||
}) |
||||
..compose(Delta()..insert('test '), |
||||
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); |
||||
|
||||
expect(listenerCalled, isTrue); |
||||
expect(controller.document.toDelta(), |
||||
Delta()..insert('test $originalContents')); |
||||
}); |
||||
}); |
||||
} |
@ -0,0 +1,82 @@ |
||||
import 'dart:convert' show jsonDecode; |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
import 'package:flutter_quill/flutter_quill_test.dart'; |
||||
import 'package:flutter_test/flutter_test.dart'; |
||||
|
||||
void main() { |
||||
late QuillController controller; |
||||
|
||||
setUp(() { |
||||
controller = QuillController.basic(); |
||||
}); |
||||
|
||||
tearDown(() { |
||||
controller.dispose(); |
||||
}); |
||||
|
||||
group('QuillEditor', () { |
||||
testWidgets('Keyboard entered text is stored in document', (tester) async { |
||||
await tester.pumpWidget( |
||||
MaterialApp( |
||||
home: QuillEditor.basic(controller: controller, readOnly: false), |
||||
), |
||||
); |
||||
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||
|
||||
expect(controller.document.toPlainText(), 'test\n'); |
||||
}); |
||||
|
||||
testWidgets('insertContent is handled correctly', (tester) async { |
||||
String? latestUri; |
||||
await tester.pumpWidget( |
||||
MaterialApp( |
||||
home: QuillEditor( |
||||
controller: controller, |
||||
focusNode: FocusNode(), |
||||
scrollController: ScrollController(), |
||||
scrollable: true, |
||||
padding: const EdgeInsets.all(0), |
||||
autoFocus: true, |
||||
readOnly: false, |
||||
expands: true, |
||||
contentInsertionConfiguration: ContentInsertionConfiguration( |
||||
onContentInserted: (content) { |
||||
latestUri = content.uri; |
||||
}, |
||||
allowedMimeTypes: const <String>['image/gif'], |
||||
), |
||||
), |
||||
), |
||||
); |
||||
await tester.tap(find.byType(QuillEditor)); |
||||
await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); |
||||
await tester.idle(); |
||||
|
||||
const uri = |
||||
'content://com.google.android.inputmethod.latin.fileprovider/test.gif'; |
||||
final messageBytes = |
||||
const JSONMessageCodec().encodeMessage(<String, dynamic>{ |
||||
'args': <dynamic>[ |
||||
-1, |
||||
'TextInputAction.commitContent', |
||||
jsonDecode( |
||||
'{"mimeType": "image/gif", "data": [0,1,0,1,0,1,0,0,0], "uri": "$uri"}'), |
||||
], |
||||
'method': 'TextInputClient.performAction', |
||||
}); |
||||
|
||||
Object? error; |
||||
try { |
||||
await tester.binding.defaultBinaryMessenger |
||||
.handlePlatformMessage('flutter/textinput', messageBytes, (_) {}); |
||||
} catch (e) { |
||||
error = e; |
||||
} |
||||
expect(error, isNull); |
||||
expect(latestUri, equals(uri)); |
||||
}); |
||||
}); |
||||
} |
Loading…
Reference in new issue