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 |
## 0.1.0 |
||||||
|
|
||||||
* Initial release |
* 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'; |
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
class QuillCustomButton { |
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 |
///The icon widget |
||||||
final IconData? icon; |
final IconData? icon; |
||||||
|
|
||||||
|
///The icon color; |
||||||
|
final Color? iconColor; |
||||||
|
|
||||||
///The function when the icon is tapped |
///The function when the icon is tapped |
||||||
final VoidCallback? onTap; |
final VoidCallback? onTap; |
||||||
|
|
||||||
///The customButton placeholder |
///The customButton placeholder |
||||||
final Widget? child; |
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