From 4face9dc23b4a7f88f5548c42a6633ca050ba3a6 Mon Sep 17 00:00:00 2001 From: kuba Date: Sun, 9 Oct 2022 00:14:45 -0400 Subject: [PATCH] Allow custom attributes to be added into the Attribute registry. Export the Rule class so that it's possible to add custom rules. Make the custom button apply the styles defined in the icon theme and add isToggled function to determine whether the button should apply the toggled style. Also, allow the button to be further customizable by defining a custom builder implementation. Fix the demo pages and add a demo page showcasing a custom attribute. --- README.md | 75 ++++++++++ example/lib/pages/custom_attr_page.dart | 128 +++++++++++++++++ example/lib/pages/home_page.dart | 19 +++ example/lib/pages/read_only_page.dart | 5 +- example/lib/widgets/demo_scaffold.dart | 35 +++-- lib/flutter_quill.dart | 1 + lib/src/models/documents/attribute.dart | 4 + .../models/themes/quill_custom_button.dart | 131 +++++++++++++++++- lib/src/widgets/toolbar.dart | 12 +- 9 files changed, 379 insertions(+), 31 deletions(-) create mode 100644 example/lib/pages/custom_attr_page.dart diff --git a/README.md b/README.md index 06fcd9e8..4cbb200e 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,81 @@ QuillToolbar.basic( ] ``` +### Custom Attributes +Often times we want to apply a style that is not available by default with the use of existing toolbar buttons. For such cases, we can simply add a custom toolbar button to apply a custom attribute to the selected text. + +We can create a custom attribute by defining the class as such: +```dart +class CustomAttribute extends Attribute { + const CustomAttribute(bool? val) + : super(KEY, AttributeScope.INLINE, val); + + static const String KEY = 'my-custom-attr'; +} +``` + +We should then register the attribute when the app is started. + +```dart +void main() { + Attribute.addCustomAttribute(const CustomAttribute(null)); + runApp(MyApp()); +} +``` + +After that we can define a custom style builder and handle the text style that will be applied for the given attribute. For example, the below code will apply a blue color and yellow background if a text contains this attribute. +```dart + TextStyle _customStyleBuilder(Attribute attr) { + if (attr.key == CustomAttribute.KEY) { + return TextStyle( + color: Colors.blue, backgroundColor: Colors.yellow); + } + + return const TextStyle(); + } + + Widget build(BuildContext context) { + var quillEditor = QuillEditor( + ... + customStyleBuilder: _customStyleBuilder, + ); + ... + } +``` + +You can then add a custom toolbar button that will apply this attribute to the selected text. +```dart + var toolbar = QuillToolbar.basic( + controller: controller, + customButtons: [ + QuillCustomButton( + icon: Icons.smart_toy_sharp, + onTap: () { + if (controller + .getSelectionStyle() + .attributes + .keys + .contains(CustomAttribute.KEY)) { + // remove the attribute if it's already applied + controller + .formatSelection(const CustomAttribute(null)); + } else { + // add the attribute + controller + .formatSelection(const CustomAttribute(true)); + } + }, + isToggled: () { + // determine whether the button should be in a toggled state + return controller + .getSelectionStyle() + .attributes + .containsKey(CustomAttribute.KEY); + }) + ], +); +``` + ## Embed Blocks diff --git a/example/lib/pages/custom_attr_page.dart b/example/lib/pages/custom_attr_page.dart new file mode 100644 index 00000000..5eb23217 --- /dev/null +++ b/example/lib/pages/custom_attr_page.dart @@ -0,0 +1,128 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; + +import '../universal_ui/universal_ui.dart'; +import '../widgets/demo_scaffold.dart'; + +class CustomAttrPage extends StatefulWidget { + CustomAttrPage() { + // should probably be called when the app first starts but since the + // registry is a hashmap then it won't really matter for this example + Attribute.addCustomAttribute(const RandomColorAttribute(true)); + } + + @override + _CustomAttrPageState createState() => _CustomAttrPageState(); +} + +class _CustomAttrPageState extends State { + final FocusNode _focusNode = FocusNode(); + QuillController? _controller; + + @override + Widget build(BuildContext context) { + return DemoScaffold( + documentFilename: 'sample_data_nomedia.json', + builder: _buildContent, + customButtons: [ + QuillCustomButton( + icon: Icons.smart_toy_sharp, + onTap: () { + if (_controller != null) { + if (_controller! + .getSelectionStyle() + .attributes + .keys + .contains(RandomColorAttribute.KEY)) { + _controller! + .formatSelection(const RandomColorAttribute(null)); + } else { + _controller! + .formatSelection(const RandomColorAttribute(true)); + } + } + }, + isToggled: () { + return _controller != null && + _controller! + .getSelectionStyle() + .attributes + .containsKey(RandomColorAttribute.KEY); + }) + ], + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + // update ui and randomize the colors + }); + }, + child: const Icon(Icons.refresh), + ), + title: 'Custom attribute demo', + ); + } + + Widget _buildContent(BuildContext context, QuillController? controller) { + _controller = controller; + var quillEditor = QuillEditor( + controller: controller!, + scrollController: ScrollController(), + scrollable: true, + focusNode: _focusNode, + autoFocus: true, + readOnly: false, + expands: false, + padding: EdgeInsets.zero, + embedBuilders: FlutterQuillEmbeds.builders(), + customStyleBuilder: _customStyleBuilder, + ); + if (kIsWeb) { + quillEditor = QuillEditor( + controller: controller, + scrollController: ScrollController(), + scrollable: true, + focusNode: _focusNode, + autoFocus: true, + readOnly: false, + expands: false, + padding: EdgeInsets.zero, + embedBuilders: defaultEmbedBuildersWeb, + customStyleBuilder: _customStyleBuilder, + ); + } + + return Padding( + padding: const EdgeInsets.all(8), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade200), + ), + child: quillEditor, + ), + ); + } + + TextStyle _customStyleBuilder(Attribute attr) { + if (attr.key == RandomColorAttribute.KEY) { + // generate a random text color + return TextStyle( + color: + Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1)); + } + + return const TextStyle(); + } +} + +// custom inline attribute +class RandomColorAttribute extends Attribute { + const RandomColorAttribute(bool? val) + : super(KEY, AttributeScope.INLINE, val); + + static const String KEY = 'random-color'; +} diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index fcff61f2..2f678e60 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -15,6 +15,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:tuple/tuple.dart'; import '../universal_ui/universal_ui.dart'; +import 'custom_attr_page.dart'; import 'read_only_page.dart'; class HomePage extends StatefulWidget { @@ -342,11 +343,19 @@ class _HomePageState extends State { indent: size.width * 0.1, endIndent: size.width * 0.1, ), + ListTile( + title: + const Center(child: Text('Custom attr demo', style: itemStyle)), + dense: true, + visualDensity: VisualDensity.compact, + onTap: _openCustomAttrPage, + ), ], ); } void _readOnly() { + Navigator.pop(super.context); Navigator.push( super.context, MaterialPageRoute( @@ -355,6 +364,16 @@ class _HomePageState extends State { ); } + void _openCustomAttrPage() { + Navigator.pop(super.context); + Navigator.push( + super.context, + MaterialPageRoute( + builder: (context) => CustomAttrPage(), + ), + ); + } + Future _onImagePaste(Uint8List imageBytes) async { // Saves the image to applications directory final appDocDir = await getApplicationDocumentsDirectory(); diff --git a/example/lib/pages/read_only_page.dart b/example/lib/pages/read_only_page.dart index d0ae032e..fb29ccd9 100644 --- a/example/lib/pages/read_only_page.dart +++ b/example/lib/pages/read_only_page.dart @@ -20,15 +20,14 @@ class _ReadOnlyPageState extends State { @override Widget build(BuildContext context) { return DemoScaffold( - documentFilename: isDesktop() - ? 'assets/sample_data_nomedia.json' - : 'sample_data_nomedia.json', + documentFilename: 'sample_data_nomedia.json', builder: _buildContent, showToolbar: _edit == true, floatingActionButton: FloatingActionButton.extended( label: Text(_edit == true ? 'Done' : 'Edit'), onPressed: _toggleEdit, icon: Icon(_edit == true ? Icons.check : Icons.edit)), + title: 'Read only demo', ); } diff --git a/example/lib/widgets/demo_scaffold.dart b/example/lib/widgets/demo_scaffold.dart index 0b14663c..31849295 100644 --- a/example/lib/widgets/demo_scaffold.dart +++ b/example/lib/widgets/demo_scaffold.dart @@ -20,6 +20,8 @@ class DemoScaffold extends StatefulWidget { this.actions, this.showToolbar = true, this.floatingActionButton, + this.customButtons, + this.title = '', Key? key, }) : super(key: key); @@ -29,24 +31,23 @@ class DemoScaffold extends StatefulWidget { final List? actions; final Widget? floatingActionButton; final bool showToolbar; + final List? customButtons; + final String title; @override _DemoScaffoldState createState() => _DemoScaffoldState(); } class _DemoScaffoldState extends State { - final _scaffoldKey = GlobalKey(); QuillController? _controller; bool _loading = false; @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (_controller == null && !_loading) { - _loading = true; - _loadFromAssets(); - } + void initState() { + super.initState(); + _loading = true; + _loadFromAssets(); } @override @@ -93,36 +94,34 @@ class _DemoScaffoldState extends State { var toolbar = QuillToolbar.basic( controller: _controller!, embedButtons: FlutterQuillEmbeds.buttons(), + customButtons: widget.customButtons ?? [], ); if (_isDesktop()) { toolbar = QuillToolbar.basic( controller: _controller!, embedButtons: FlutterQuillEmbeds.buttons( filePickImpl: openFileSystemPickerForDesktop), + customButtons: widget.customButtons ?? [], ); } return Scaffold( - key: _scaffoldKey, appBar: AppBar( elevation: 0, - backgroundColor: Theme.of(context).canvasColor, + backgroundColor: Colors.grey.shade800, + title: Text(widget.title), centerTitle: false, titleSpacing: 0, - leading: IconButton( - icon: Icon( - Icons.chevron_left, - color: Colors.grey.shade800, - size: 18, - ), - onPressed: () => Navigator.pop(context), - ), - title: _loading || !widget.showToolbar ? null : toolbar, actions: actions, ), floatingActionButton: widget.floatingActionButton, body: _loading ? const Center(child: Text('Loading...')) : widget.builder(context, _controller), + bottomNavigationBar: _loading || !widget.showToolbar + ? null + : BottomAppBar( + child: toolbar, + ), ); } diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index eae6c5de..c0ba3524 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -7,6 +7,7 @@ export 'src/models/documents/nodes/leaf.dart'; export 'src/models/documents/nodes/node.dart'; export 'src/models/documents/style.dart'; export 'src/models/quill_delta.dart'; +export 'src/models/rules/rule.dart'; export 'src/models/themes/quill_custom_button.dart'; export 'src/models/themes/quill_dialog_theme.dart'; export 'src/models/themes/quill_icon_theme.dart'; diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index 2ec7d7f2..5cf61e0a 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -17,6 +17,10 @@ class Attribute { final AttributeScope scope; final T value; + static void addCustomAttribute(Attribute attr) { + _registry[attr.key] = attr; + } + static final Map _registry = LinkedHashMap.of({ Attribute.bold.key: Attribute.bold, Attribute.italic.key: Attribute.italic, diff --git a/lib/src/models/themes/quill_custom_button.dart b/lib/src/models/themes/quill_custom_button.dart index 0dbac618..bc1442b8 100644 --- a/lib/src/models/themes/quill_custom_button.dart +++ b/lib/src/models/themes/quill_custom_button.dart @@ -1,11 +1,136 @@ import 'package:flutter/material.dart'; +import '../../../flutter_quill.dart'; + class QuillCustomButton { - const QuillCustomButton({this.icon, this.onTap}); + const QuillCustomButton( + {this.icon, + this.onTap, + this.isToggled, + this.builder = defaultCustomButtonBuilder}); - ///The icon widget + // The icon widget final IconData? icon; - ///The function when the icon is tapped + // The function when the icon is tapped final VoidCallback? onTap; + + // The function to determine whether the button is toggled + final CustomButtonToggled? isToggled; + + // Can specify a custom builder to build the widget + final CustomButtonBuilder builder; } + +class QuillCustomButtonWidget extends StatefulWidget { + const QuillCustomButtonWidget( + {required this.button, + required this.controller, + required this.iconSize, + this.iconTheme, + this.afterPressed}); + + final QuillCustomButton button; + final QuillController controller; + final double iconSize; + final QuillIconTheme? iconTheme; + final VoidCallback? afterPressed; + + @override + State createState() => + _QuillCustomButtonWidgetState(); +} + +class _QuillCustomButtonWidgetState extends State { + bool _toggled = false; + + @override + void initState() { + super.initState(); + + // set the initial toggled state + _toggled = + widget.button.isToggled != null ? widget.button.isToggled!() : false; + + // add listener to update the toggled state + widget.controller.addListener(_didChangeEditingValue); + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + void _didChangeEditingValue() { + final toggled = + widget.button.isToggled != null ? widget.button.isToggled!() : false; + + if (toggled != _toggled) { + setState(() { + _toggled = toggled; + }); + } + } + + @override + Widget build(BuildContext context) { + return widget.button.builder( + context, + widget.controller, + widget.button.icon, + widget.iconSize, + widget.iconTheme, + _toggled, + widget.button.onTap, + widget.afterPressed); + } +} + +typedef CustomButtonBuilder = Widget Function( + BuildContext context, + QuillController controller, + IconData? icon, + double iconSize, + QuillIconTheme? iconTheme, + bool isToggled, + VoidCallback? onPressed, + VoidCallback? afterPressed, +); + +Widget defaultCustomButtonBuilder( + BuildContext context, + QuillController controller, + IconData? icon, + double iconSize, + QuillIconTheme? iconTheme, + bool isToggled, + VoidCallback? onPressed, + VoidCallback? afterPressed, +) { + final theme = Theme.of(context); + final isEnabled = onPressed != null; + final iconColor = isEnabled + ? isToggled + ? (iconTheme?.iconSelectedColor ?? theme.primaryIconTheme.color) + : (iconTheme?.iconUnselectedColor ?? theme.iconTheme.color) + : (iconTheme?.disabledIconColor ?? theme.disabledColor); + final fill = isEnabled + ? isToggled + ? (iconTheme?.iconSelectedFillColor ?? theme.toggleableActiveColor) + : (iconTheme?.iconUnselectedFillColor ?? theme.canvasColor) + : (iconTheme?.disabledIconFillColor ?? theme.canvasColor); + + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: iconSize * kIconButtonFactor, + icon: Icon(icon, size: iconSize, color: iconColor), + fillColor: fill, + onPressed: onPressed, + afterPressed: afterPressed, + borderRadius: iconTheme?.borderRadius ?? 2, + ); +} + +typedef CustomButtonToggled = bool Function(); diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 9f11eaf5..79ed5062 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -486,13 +486,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { color: Colors.grey.shade400, ), for (var customButton in customButtons) - QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: toolbarIconSize * kIconButtonFactor, - icon: Icon(customButton.icon, size: toolbarIconSize), - borderRadius: iconTheme?.borderRadius ?? 2, - onPressed: customButton.onTap, + QuillCustomButtonWidget( + button: customButton, + controller: controller, + iconSize: toolbarIconSize, + iconTheme: iconTheme, afterPressed: afterButtonPressed, ), ],