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, ), ],