From 92b45f77921319d7dd42ef567b2ea1c29d864003 Mon Sep 17 00:00:00 2001 From: singerdmx Date: Tue, 22 Dec 2020 15:03:13 -0800 Subject: [PATCH] Add QuillToolbar --- lib/models/documents/nodes/embed.dart | 4 + lib/widgets/toolbar.dart | 644 ++++++++++++++++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 lib/widgets/toolbar.dart diff --git a/lib/models/documents/nodes/embed.dart b/lib/models/documents/nodes/embed.dart index 5b8708ff..7c004251 100644 --- a/lib/models/documents/nodes/embed.dart +++ b/lib/models/documents/nodes/embed.dart @@ -77,4 +77,8 @@ class BlockEmbed extends Embeddable { String type, { Map data = const {}, }) : super(type, false, data); + + static final BlockEmbed horizontalRule = BlockEmbed('hr'); + static BlockEmbed image(String source) => + BlockEmbed('image', data: {'source': source}); } diff --git a/lib/widgets/toolbar.dart b/lib/widgets/toolbar.dart new file mode 100644 index 00000000..bfe182d7 --- /dev/null +++ b/lib/widgets/toolbar.dart @@ -0,0 +1,644 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/models/documents/attribute.dart'; +import 'package:flutter_quill/models/documents/nodes/embed.dart'; +import 'package:flutter_quill/models/documents/style.dart'; + +import 'controller.dart'; + +const double kToolbarHeight = 56.0; + +class InsertEmbedButton extends StatelessWidget { + final QuillController controller; + final IconData icon; + + const InsertEmbedButton({ + Key key, + @required this.controller, + @required this.icon, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: 32, + icon: Icon( + icon, + size: 18, + color: Theme.of(context).iconTheme.color, + ), + fillColor: Theme.of(context).canvasColor, + onPressed: () { + final index = controller.selection.baseOffset; + final length = controller.selection.extentOffset - index; + controller.replaceText(index, length, BlockEmbed.horizontalRule, null); + }, + ); + } +} + +class LinkStyleButton extends StatefulWidget { + final QuillController controller; + final IconData icon; + + const LinkStyleButton({ + Key key, + @required this.controller, + this.icon, + }) : super(key: key); + + @override + _LinkStyleButtonState createState() => _LinkStyleButtonState(); +} + +class _LinkStyleButtonState extends State { + void _didChangeSelection() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_didChangeSelection); + } + + @override + void didUpdateWidget(covariant LinkStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeSelection); + widget.controller.addListener(_didChangeSelection); + } + } + + @override + void dispose() { + super.dispose(); + widget.controller.removeListener(_didChangeSelection); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEnabled = !widget.controller.selection.isCollapsed; + final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: 32, + icon: Icon( + widget.icon ?? Icons.link, + size: 18, + color: isEnabled ? theme.iconTheme.color : theme.disabledColor, + ), + fillColor: Theme.of(context).canvasColor, + onPressed: pressedHandler, + ); + } + + void _openLinkDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) { + return _LinkDialog(); + }, + ).then(_linkSubmitted); + } + + void _linkSubmitted(String value) { + if (value == null || value.isEmpty) { + return; + } + widget.controller.formatSelection(LinkAttribute(value)); + } +} + +class _LinkDialog extends StatefulWidget { + const _LinkDialog({Key key}) : super(key: key); + + @override + _LinkDialogState createState() => _LinkDialogState(); +} + +class _LinkDialogState extends State<_LinkDialog> { + String _link = ''; + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: TextField( + decoration: InputDecoration(labelText: 'Paste a link'), + autofocus: true, + onChanged: _linkChanged, + ), + actions: [ + FlatButton( + onPressed: _link.isNotEmpty ? _applyLink : null, + child: Text('Apply'), + ), + ], + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + void _applyLink() { + Navigator.pop(context, _link); + } +} + +typedef ToggleStyleButtonBuilder = Widget Function( + BuildContext context, + Attribute attribute, + IconData icon, + bool isToggled, + VoidCallback onPressed, +); + +class ToggleStyleButton extends StatefulWidget { + final Attribute attribute; + + final IconData icon; + + final QuillController controller; + + final ToggleStyleButtonBuilder childBuilder; + + ToggleStyleButton({ + Key key, + @required this.attribute, + @required this.icon, + @required this.controller, + this.childBuilder = defaultToggleStyleButtonBuilder, + }) : assert(attribute.value != null), + assert(icon != null), + assert(controller != null), + assert(childBuilder != null), + super(key: key); + + @override + _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); +} + +class _ToggleStyleButtonState extends State { + bool _isToggled; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _isToggled = widget.controller + .getSelectionStyle() + .attributes + .containsKey(widget.attribute.key); + }); + } + + @override + void initState() { + super.initState(); + _isToggled = _selectionStyle.attributes.containsKey(widget.attribute.key); + widget.controller.addListener(_didChangeEditingValue); + } + + @override + void didUpdateWidget(covariant ToggleStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _isToggled = _selectionStyle.attributes.containsKey(widget.attribute.key); + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isInCodeBlock = + _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); + final isEnabled = + !isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; + return widget.childBuilder(context, widget.attribute, widget.icon, + _isToggled, isEnabled ? _toggleAttribute : null); + } + + _toggleAttribute() { + if (_isToggled) { + widget.controller + .formatSelection(Attribute.clone(widget.attribute, null)); + } else { + widget.controller.formatSelection(widget.attribute); + } + } +} + +Widget defaultToggleStyleButtonBuilder( + BuildContext context, + Attribute attribute, + IconData icon, + bool isToggled, + VoidCallback onPressed, +) { + final theme = Theme.of(context); + final isEnabled = onPressed != null; + final iconColor = isEnabled + ? isToggled + ? theme.primaryIconTheme.color + : theme.iconTheme.color + : theme.disabledColor; + final fillColor = isToggled ? theme.toggleableActiveColor : theme.canvasColor; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: 32, + icon: Icon(icon, size: 18, color: iconColor), + fillColor: fillColor, + onPressed: onPressed, + ); +} + +class SelectHeadingStyleButton extends StatefulWidget { + final QuillController controller; + + const SelectHeadingStyleButton({Key key, @required this.controller}) + : super(key: key); + + @override + _SelectHeadingStyleButtonState createState() => + _SelectHeadingStyleButtonState(); +} + +class _SelectHeadingStyleButtonState extends State { + Attribute _value; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + void _didChangeEditingValue() { + setState(() { + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + }); + } + + void _selectAttribute(value) { + widget.controller.formatSelection(value); + } + + @override + void initState() { + super.initState(); + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + widget.controller.addListener(_didChangeEditingValue); + } + + @override + void didUpdateWidget(covariant SelectHeadingStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _selectHeadingStyleButtonBuilder(context, _value, _selectAttribute); + } +} + +Widget _selectHeadingStyleButtonBuilder( + BuildContext context, Attribute value, ValueChanged onSelected) { + final style = TextStyle(fontSize: 12); + + final Map _valueToText = { + Attribute.header: 'Normal text', + Attribute.h1: 'Heading 1', + Attribute.h2: 'Heading 2', + Attribute.h3: 'Heading 3', + }; + + return QuillDropdownButton( + highlightElevation: 0, + hoverElevation: 0, + height: 32, + child: Text( + _valueToText[value], + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + initialValue: value, + items: [ + PopupMenuItem( + child: Text(_valueToText[Attribute.header], style: style), + value: Attribute.header, + height: 32, + ), + PopupMenuItem( + child: Text(_valueToText[Attribute.h1], style: style), + value: Attribute.h1, + height: 32, + ), + PopupMenuItem( + child: Text(_valueToText[Attribute.h2], style: style), + value: Attribute.h2, + height: 32, + ), + PopupMenuItem( + child: Text(_valueToText[Attribute.h3], style: style), + value: Attribute.h3, + height: 32, + ), + ], + onSelected: onSelected, + ); +} + +class QuillToolbar extends StatefulWidget implements PreferredSizeWidget { + final List children; + + const QuillToolbar({Key key, @required this.children}) : super(key: key); + + factory QuillToolbar.basic( + {Key key, + @required QuillController controller, + bool hideBoldButton = false, + bool hideItalicButton = false, + bool hideUnderLineButton = false, + bool hideStrikeThrough = false, + bool hideHeadingStyle = false, + bool hideListNumbers = false, + bool hideListBullets = false, + bool hideCodeBlock = false, + bool hideQuote = false, + bool hideLink = false, + bool hideHorizontalRule = false}) { + return QuillToolbar(key: key, children: [ + Visibility( + visible: hideBoldButton, + child: ToggleStyleButton( + attribute: Attribute.bold, + icon: Icons.format_bold, + controller: controller, + ), + ), + SizedBox(width: 1), + Visibility( + visible: hideItalicButton, + child: ToggleStyleButton( + attribute: Attribute.italic, + icon: Icons.format_italic, + controller: controller, + ), + ), + SizedBox(width: 1), + Visibility( + visible: hideUnderLineButton, + child: ToggleStyleButton( + attribute: Attribute.underline, + icon: Icons.format_underline, + controller: controller, + ), + ), + SizedBox(width: 1), + Visibility( + visible: hideStrikeThrough, + child: ToggleStyleButton( + attribute: Attribute.strikeThrough, + icon: Icons.format_strikethrough, + controller: controller, + ), + ), + Visibility( + visible: hideHeadingStyle, + child: VerticalDivider( + indent: 16, endIndent: 16, color: Colors.grey.shade400)), + Visibility( + visible: hideHeadingStyle, + child: SelectHeadingStyleButton(controller: controller)), + VerticalDivider(indent: 16, endIndent: 16, color: Colors.grey.shade400), + Visibility( + visible: hideListNumbers, + child: ToggleStyleButton( + attribute: Attribute.ol, + controller: controller, + icon: Icons.format_list_numbered, + ), + ), + Visibility( + visible: hideListBullets, + child: ToggleStyleButton( + attribute: Attribute.ul, + controller: controller, + icon: Icons.format_list_bulleted, + ), + ), + Visibility( + visible: hideCodeBlock, + child: ToggleStyleButton( + attribute: Attribute.codeBlock, + controller: controller, + icon: Icons.code, + ), + ), + Visibility( + visible: !hideListNumbers && !hideListBullets && !hideCodeBlock, + child: VerticalDivider( + indent: 16, endIndent: 16, color: Colors.grey.shade400)), + Visibility( + visible: hideQuote, + child: ToggleStyleButton( + attribute: Attribute.blockQuote, + controller: controller, + icon: Icons.format_quote, + ), + ), + Visibility( + visible: hideQuote, + child: VerticalDivider( + indent: 16, endIndent: 16, color: Colors.grey.shade400)), + Visibility( + visible: hideLink, child: LinkStyleButton(controller: controller)), + Visibility( + visible: hideHorizontalRule, + child: InsertEmbedButton( + controller: controller, + icon: Icons.horizontal_rule, + ), + ), + ]); + } + + @override + _QuillToolbarState createState() => _QuillToolbarState(); + + @override + Size get preferredSize => Size.fromHeight(kToolbarHeight); +} + +class _QuillToolbarState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8), + constraints: BoxConstraints.tightFor(height: widget.preferredSize.height), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: widget.children, + ), + ), + ); + } +} + +class QuillIconButton extends StatelessWidget { + final VoidCallback onPressed; + final Widget icon; + final double size; + final Color fillColor; + final double hoverElevation; + final double highlightElevation; + + const QuillIconButton({ + Key key, + @required this.onPressed, + this.icon, + this.size = 40, + this.fillColor, + this.hoverElevation = 1, + this.highlightElevation = 1, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(width: size, height: size), + child: RawMaterialButton( + child: icon, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + padding: EdgeInsets.zero, + fillColor: fillColor, + elevation: 0, + hoverElevation: hoverElevation, + highlightElevation: hoverElevation, + onPressed: onPressed, + ), + ); + } +} + +class QuillDropdownButton extends StatefulWidget { + final double height; + final Color fillColor; + final double hoverElevation; + final double highlightElevation; + final Widget child; + final T initialValue; + final List> items; + final ValueChanged onSelected; + + const QuillDropdownButton({ + Key key, + this.height = 40, + this.fillColor, + this.hoverElevation = 1, + this.highlightElevation = 1, + @required this.child, + @required this.initialValue, + @required this.items, + @required this.onSelected, + }) : super(key: key); + + @override + _QuillDropdownButtonState createState() => _QuillDropdownButtonState(); +} + +class _QuillDropdownButtonState extends State> { + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(height: widget.height), + child: RawMaterialButton( + child: _buildContent(context), + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + padding: EdgeInsets.zero, + fillColor: widget.fillColor, + elevation: 0, + hoverElevation: widget.hoverElevation, + highlightElevation: widget.hoverElevation, + onPressed: _showMenu, + ), + ); + } + + void _showMenu() { + final popupMenuTheme = PopupMenuTheme.of(context); + final button = context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomLeft(Offset.zero), + ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + showMenu( + context: context, + elevation: 4, + // widget.elevation ?? popupMenuTheme.elevation, + initialValue: widget.initialValue, + items: widget.items, + position: position, + shape: popupMenuTheme.shape, + // widget.shape ?? popupMenuTheme.shape, + color: popupMenuTheme.color, // widget.color ?? popupMenuTheme.color, + // captureInheritedThemes: widget.captureInheritedThemes, + ).then((T newValue) { + if (!mounted) return null; + if (newValue == null) { + // if (widget.onCanceled != null) widget.onCanceled(); + return null; + } + if (widget.onSelected != null) { + widget.onSelected(newValue); + } + }); + } + + Widget _buildContent(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.tightFor(width: 110), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + children: [ + widget.child, + Expanded(child: Container()), + Icon(Icons.arrow_drop_down, size: 14) + ], + ), + ), + ); + } +}