diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 8abf61d9..36acd869 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -149,18 +149,23 @@ class _HomePageState extends State { onVideoPickCallback: _onVideoPickCallback, // uncomment to provide a custom "pick from" dialog. // mediaPickSettingSelector: _selectMediaPickSetting, + showAlignmentButtons: true, ); if (kIsWeb) { toolbar = QuillToolbar.basic( - controller: _controller!, - onImagePickCallback: _onImagePickCallback, - webImagePickImpl: _webImagePickImpl); + controller: _controller!, + onImagePickCallback: _onImagePickCallback, + webImagePickImpl: _webImagePickImpl, + showAlignmentButtons: true, + ); } if (_isDesktop()) { toolbar = QuillToolbar.basic( - controller: _controller!, - onImagePickCallback: _onImagePickCallback, - filePickImpl: openFileSystemPickerForDesktop); + controller: _controller!, + onImagePickCallback: _onImagePickCallback, + filePickImpl: openFileSystemPickerForDesktop, + showAlignmentButtons: true, + ); } return SafeArea( diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements index 51d09670..d6c8ee0c 100644 --- a/example/macos/Runner/DebugProfile.entitlements +++ b/example/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index ddfcf7c3..92772e9f 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -4,9 +4,9 @@ #include "generated_plugin_registrant.h" -#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { - UrlLauncherPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/lib/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index 467742d5..f52f70b7 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -111,6 +111,13 @@ class Attribute { Attribute.indent.key, }); + static final Set exclusiveBlockKeys = LinkedHashSet.of({ + Attribute.header.key, + Attribute.list.key, + Attribute.codeBlock.key, + Attribute.blockQuote.key, + }); + static Attribute get h1 => HeaderAttribute(level: 1); static Attribute get h2 => HeaderAttribute(level: 2); diff --git a/lib/src/models/documents/nodes/line.dart b/lib/src/models/documents/nodes/line.dart index fa5f6c90..bccd7fb8 100644 --- a/lib/src/models/documents/nodes/line.dart +++ b/lib/src/models/documents/nodes/line.dart @@ -202,11 +202,26 @@ class Line extends Container { if (parent is Block) { final parentStyle = (parent as Block).style.getBlocksExceptHeader(); - if (blockStyle.value == null) { + // Ensure that we're only unwrapping the block only if we unset a single + // block format in the `parentStyle` and there are no more block formats + // left to unset. + if (blockStyle.value == null && + parentStyle.containsKey(blockStyle.key) && + parentStyle.length == 1) { _unwrap(); } else if (!const MapEquality() .equals(newStyle.getBlocksExceptHeader(), parentStyle)) { _unwrap(); + // Block style now can contain multiple attributes + if (newStyle.attributes.keys + .any(Attribute.exclusiveBlockKeys.contains)) { + parentStyle.removeWhere( + (key, attr) => Attribute.exclusiveBlockKeys.contains(key)); + } + parentStyle.removeWhere( + (key, attr) => newStyle?.attributes.keys.contains(key) ?? false); + final parentStyleToMerge = Style.attr(parentStyle); + newStyle = newStyle.mergeAll(parentStyleToMerge); _applyBlockStyles(newStyle); } // else the same style, no-op. } else if (blockStyle.value != null) { diff --git a/lib/src/models/rules/format.dart b/lib/src/models/rules/format.dart index be201925..38124af8 100644 --- a/lib/src/models/rules/format.dart +++ b/lib/src/models/rules/format.dart @@ -39,10 +39,23 @@ class ResolveLineFormatRule extends FormatRule { final tmp = Delta(); var offset = 0; + // Enforce Block Format exclusivity by rule + final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key) + ? op.attributes?.keys + .where((key) => + Attribute.exclusiveBlockKeys.contains(key) && + attribute.key != key && + attribute.value != null) + .map((key) => MapEntry(key, null)) ?? + [] + : >[]; + for (var lineBreak = text.indexOf('\n'); lineBreak >= 0; lineBreak = text.indexOf('\n', offset)) { - tmp..retain(lineBreak - offset)..retain(1, attribute.toJson()); + tmp + ..retain(lineBreak - offset) + ..retain(1, attribute.toJson()..addEntries(removedBlocks)); offset = lineBreak + 1; } tmp.retain(text.length - offset); @@ -57,7 +70,19 @@ class ResolveLineFormatRule extends FormatRule { delta.retain(op.length!); continue; } - delta..retain(lineBreak)..retain(1, attribute.toJson()); + // Enforce Block Format exclusivity by rule + final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key) + ? op.attributes?.keys + .where((key) => + Attribute.exclusiveBlockKeys.contains(key) && + attribute.key != key && + attribute.value != null) + .map((key) => MapEntry(key, null)) ?? + [] + : >[]; + delta + ..retain(lineBreak) + ..retain(1, attribute.toJson()..addEntries(removedBlocks)); break; } return delta; diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 09b4940b..d88a49e5 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -313,8 +313,12 @@ class RawEditorState extends EditorState return defaultStyles!.code!.verticalSpacing; } else if (attrs.containsKey(Attribute.indent.key)) { return defaultStyles!.indent!.verticalSpacing; + } else if (attrs.containsKey(Attribute.list.key)) { + return defaultStyles!.lists!.verticalSpacing; + } else if (attrs.containsKey(Attribute.align.key)) { + return defaultStyles!.align!.verticalSpacing; } - return defaultStyles!.lists!.verticalSpacing; + return const Tuple2(0, 0); } @override diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart index 2d5fa451..8d59686a 100644 --- a/lib/src/widgets/simple_viewer.dart +++ b/lib/src/widgets/simple_viewer.dart @@ -295,8 +295,12 @@ class _QuillSimpleViewerState extends State return defaultStyles!.code!.verticalSpacing; } else if (attrs.containsKey(Attribute.indent.key)) { return defaultStyles!.indent!.verticalSpacing; + } else if (attrs.containsKey(Attribute.list.key)) { + return defaultStyles!.lists!.verticalSpacing; + } else if (attrs.containsKey(Attribute.align.key)) { + return defaultStyles!.align!.verticalSpacing; } - return defaultStyles!.lists!.verticalSpacing; + return const Tuple2(0, 0); } void _nullSelectionChanged( diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index e668d30b..64d00cfb 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -213,11 +213,16 @@ class EditableTextBlock extends StatelessWidget { extraIndent = 16.0 * indent.value; } + var baseIndent = 0.0; + if (attrs.containsKey(Attribute.blockQuote.key)) { return 16.0 + extraIndent; + } else if (attrs.containsKey(Attribute.list.key) || + attrs.containsKey(Attribute.codeBlock.key)) { + baseIndent = 32.0; } - return 32.0 + extraIndent; + return baseIndent + extraIndent; } Tuple2 _getSpacingForLine( diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 6da21a4c..fb700e26 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -104,11 +104,11 @@ class TextLine extends StatelessWidget { TextAlign _getTextAlign() { final alignment = line.style.attributes[Attribute.align.key]; if (alignment == Attribute.leftAlignment) { - return TextAlign.left; + return TextAlign.start; } else if (alignment == Attribute.centerAlignment) { return TextAlign.center; } else if (alignment == Attribute.rightAlignment) { - return TextAlign.right; + return TextAlign.end; } else if (alignment == Attribute.justifyAlignment) { return TextAlign.justify; } @@ -140,13 +140,20 @@ class TextLine extends StatelessWidget { textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); - final block = line.style.getBlockExceptHeader(); + // Only retrieve exclusive block format for the line style purpose + Attribute? block; + line.style.getBlocksExceptHeader().forEach((key, value) { + if (Attribute.exclusiveBlockKeys.contains(key)) { + block = value; + } + }); + TextStyle? toMerge; if (block == Attribute.blockQuote) { toMerge = defaultStyles.quote!.style; } else if (block == Attribute.codeBlock) { toMerge = defaultStyles.code!.style; - } else if (block != null) { + } else if (block == Attribute.list) { toMerge = defaultStyles.lists!.style; } diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 2803948d..80e04e01 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -14,6 +14,7 @@ import 'toolbar/image_button.dart'; import 'toolbar/indent_button.dart'; import 'toolbar/insert_embed_button.dart'; import 'toolbar/link_style_button.dart'; +import 'toolbar/select_alignment_button.dart'; import 'toolbar/select_header_style_button.dart'; import 'toolbar/toggle_check_list_button.dart'; import 'toolbar/toggle_style_button.dart'; @@ -29,6 +30,7 @@ export 'toolbar/insert_embed_button.dart'; export 'toolbar/link_style_button.dart'; export 'toolbar/quill_dropdown_button.dart'; export 'toolbar/quill_icon_button.dart'; +export 'toolbar/select_alignment_button.dart'; export 'toolbar/select_header_style_button.dart'; export 'toolbar/toggle_check_list_button.dart'; export 'toolbar/toggle_style_button.dart'; @@ -71,6 +73,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { bool showColorButton = true, bool showBackgroundColorButton = true, bool showClearFormat = true, + bool showAlignmentButtons = false, bool showHeaderStyle = true, bool showListNumbers = true, bool showListBullets = true, @@ -105,6 +108,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { showClearFormat || onImagePickCallback != null || onVideoPickCallback != null, + showAlignmentButtons, showHeaderStyle, showListNumbers || showListBullets || showListCheck || showCodeBlock, showQuote || showIndent, @@ -220,21 +224,37 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { (isButtonGroupShown[1] || isButtonGroupShown[2] || isButtonGroupShown[3] || - isButtonGroupShown[4])) + isButtonGroupShown[4] || + isButtonGroupShown[5])) VerticalDivider( indent: 12, endIndent: 12, color: Colors.grey.shade400, ), - if (showHeaderStyle) - SelectHeaderStyleButton( + if (showAlignmentButtons) + SelectAlignmentButton( controller: controller, iconSize: toolbarIconSize, ), if (isButtonGroupShown[1] && (isButtonGroupShown[2] || isButtonGroupShown[3] || - isButtonGroupShown[4])) + isButtonGroupShown[4] || + isButtonGroupShown[5])) + VerticalDivider( + indent: 12, + endIndent: 12, + color: Colors.grey.shade400, + ), + if (showHeaderStyle) + SelectHeaderStyleButton( + controller: controller, + iconSize: toolbarIconSize, + ), + if (isButtonGroupShown[2] && + (isButtonGroupShown[3] || + isButtonGroupShown[4] || + isButtonGroupShown[5])) VerticalDivider( indent: 12, endIndent: 12, @@ -268,8 +288,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { icon: Icons.code, iconSize: toolbarIconSize, ), - if (isButtonGroupShown[2] && - (isButtonGroupShown[3] || isButtonGroupShown[4])) + if (isButtonGroupShown[3] && + (isButtonGroupShown[4] || isButtonGroupShown[5])) VerticalDivider( indent: 12, endIndent: 12, @@ -296,7 +316,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, isIncrease: false, ), - if (isButtonGroupShown[3] && isButtonGroupShown[4]) + if (isButtonGroupShown[4] && isButtonGroupShown[5]) VerticalDivider( indent: 12, endIndent: 12, diff --git a/lib/src/widgets/toolbar/select_alignment_button.dart b/lib/src/widgets/toolbar/select_alignment_button.dart new file mode 100644 index 00000000..37e5e72e --- /dev/null +++ b/lib/src/widgets/toolbar/select_alignment_button.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../models/documents/attribute.dart'; +import '../../models/documents/style.dart'; +import '../controller.dart'; +import '../toolbar.dart'; + +class SelectAlignmentButton extends StatefulWidget { + const SelectAlignmentButton({ + required this.controller, + this.iconSize = kDefaultIconSize, + Key? key, + }) : super(key: key); + + final QuillController controller; + final double iconSize; + + @override + _SelectAlignmentButtonState createState() => _SelectAlignmentButtonState(); +} + +class _SelectAlignmentButtonState extends State { + Attribute? _value; + + Style get _selectionStyle => widget.controller.getSelectionStyle(); + + @override + void initState() { + super.initState(); + setState(() { + _value = _selectionStyle.attributes[Attribute.align.key] ?? + Attribute.leftAlignment; + }); + widget.controller.addListener(_didChangeEditingValue); + } + + @override + Widget build(BuildContext context) { + final _valueToText = { + Attribute.leftAlignment: Attribute.leftAlignment.value!, + Attribute.centerAlignment: Attribute.centerAlignment.value!, + Attribute.rightAlignment: Attribute.rightAlignment.value!, + Attribute.justifyAlignment: Attribute.justifyAlignment.value!, + }; + + final _valueAttribute = [ + Attribute.leftAlignment, + Attribute.centerAlignment, + Attribute.rightAlignment, + Attribute.justifyAlignment + ]; + final _valueString = [ + Attribute.leftAlignment.value!, + Attribute.centerAlignment.value!, + Attribute.rightAlignment.value!, + Attribute.justifyAlignment.value!, + ]; + + final theme = Theme.of(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(4, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: widget.iconSize * kIconButtonFactor, + height: widget.iconSize * kIconButtonFactor, + ), + child: RawMaterialButton( + hoverElevation: 0, + highlightElevation: 0, + elevation: 0, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2)), + fillColor: _valueToText[_value] == _valueString[index] + ? theme.toggleableActiveColor + : theme.canvasColor, + onPressed: () => _valueAttribute[index] == Attribute.leftAlignment + ? widget.controller + .formatSelection(Attribute.clone(Attribute.align, null)) + : widget.controller.formatSelection(_valueAttribute[index]), + child: Icon( + _valueString[index] == Attribute.leftAlignment.value + ? Icons.format_align_left + : _valueString[index] == Attribute.centerAlignment.value + ? Icons.format_align_center + : _valueString[index] == Attribute.rightAlignment.value + ? Icons.format_align_right + : Icons.format_align_justify, + size: widget.iconSize, + color: _valueToText[_value] == _valueString[index] + ? theme.primaryIconTheme.color + : theme.iconTheme.color, + ), + ), + ), + ); + }), + ); + } + + void _didChangeEditingValue() { + setState(() { + _value = _selectionStyle.attributes[Attribute.align.key] ?? + Attribute.leftAlignment; + }); + } + + @override + void didUpdateWidget(covariant SelectAlignmentButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeEditingValue); + widget.controller.addListener(_didChangeEditingValue); + _value = _selectionStyle.attributes[Attribute.align.key] ?? + Attribute.leftAlignment; + } + } + + @override + void dispose() { + widget.controller.removeListener(_didChangeEditingValue); + super.dispose(); + } +} diff --git a/lib/src/widgets/toolbar/toggle_check_list_button.dart b/lib/src/widgets/toolbar/toggle_check_list_button.dart index 861da445..c147e108 100644 --- a/lib/src/widgets/toolbar/toggle_check_list_button.dart +++ b/lib/src/widgets/toolbar/toggle_check_list_button.dart @@ -81,17 +81,13 @@ class _ToggleCheckListButtonState extends State { @override Widget build(BuildContext context) { - final isInCodeBlock = - _selectionStyle.attributes.containsKey(Attribute.codeBlock.key); - final isEnabled = - !isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; return widget.childBuilder( context, Attribute.unchecked, widget.icon, widget.fillColor, _isToggled, - isEnabled ? _toggleAttribute : null, + _toggleAttribute, widget.iconSize, ); } diff --git a/lib/src/widgets/toolbar/toggle_style_button.dart b/lib/src/widgets/toolbar/toggle_style_button.dart index 624a31f9..8299f8a4 100644 --- a/lib/src/widgets/toolbar/toggle_style_button.dart +++ b/lib/src/widgets/toolbar/toggle_style_button.dart @@ -56,17 +56,13 @@ class _ToggleStyleButtonState extends State { @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, widget.fillColor, _isToggled, - isEnabled ? _toggleAttribute : null, + _toggleAttribute, widget.iconSize, ); }