invoice/lib/utils/super_editor/toolbar.dart

906 lines
29 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:super_editor/super_editor.dart';
/// Small toolbar that is intended to display near some selected
/// text and offer a few text formatting controls.
///
/// [EditorToolbar] expects to be displayed in a [Stack] where it
/// will position itself based on the given [anchor]. This can be
/// accomplished, for example, by adding [EditorToolbar] to the
/// application [Overlay]. Any other [Stack] should work, too.
class EditorToolbar extends StatefulWidget {
const EditorToolbar({
Key? key,
required this.anchor,
required this.editorFocusNode,
required this.editor,
required this.composer,
required this.closeToolbar,
}) : super(key: key);
/// [EditorToolbar] displays itself horizontally centered and
/// slightly above the given [anchor] value.
///
/// [anchor] is a [ValueNotifier] so that [EditorToolbar] can
/// reposition itself as the [Offset] value changes.
final ValueNotifier<Offset?> anchor;
/// The [FocusNode] attached to the editor to which this toolbar applies.
final FocusNode? editorFocusNode;
/// The [editor] is used to alter document content, such as
/// when the user selects a different block format for a
/// text blob, e.g., paragraph, header, blockquote, or
/// to apply styles to text.
final DocumentEditor? editor;
/// The [composer] provides access to the user's current
/// selection within the document, which dictates the
/// content that is altered by the toolbar's options.
final DocumentComposer? composer;
/// Delegate that instructs the owner of this [EditorToolbar]
/// to close the toolbar, such as after submitting a URL
/// for some text.
final VoidCallback closeToolbar;
@override
_EditorToolbarState createState() => _EditorToolbarState();
}
class _EditorToolbarState extends State<EditorToolbar> {
bool _showUrlField = false;
FocusNode? _urlFocusNode;
AttributedTextEditingController? _urlController;
@override
void initState() {
super.initState();
_urlFocusNode = FocusNode();
_urlController = SingleLineAttributedTextEditingController(_applyLink);
}
@override
void dispose() {
_urlFocusNode!.dispose();
_urlController!.dispose();
super.dispose();
}
/*
/// Returns true if the currently selected text node is capable of being
/// transformed into a different type text node, returns false if
/// multiple nodes are selected, no node is selected, or the selected
/// node is not a standard text block.
bool _isConvertibleNode() {
final selection = widget.composer.selection;
if (selection.base.nodeId != selection.extent.nodeId) {
return false;
}
final selectedNode =
widget.editor.document.getNodeById(selection.extent.nodeId);
return selectedNode is ParagraphNode || selectedNode is ListItemNode;
}
/// Returns the block type of the currently selected text node.
///
/// Throws an exception if the currently selected node is not a text node.
_TextType _getCurrentTextType() {
final selectedNode = widget.editor.document
.getNodeById(widget.composer.selection.extent.nodeId);
if (selectedNode is ParagraphNode) {
final dynamic type = selectedNode.getMetadataValue('blockType');
if (type == header1Attribution) {
return _TextType.header1;
} else if (type == header2Attribution) {
return _TextType.header2;
} else if (type == header3Attribution) {
return _TextType.header3;
} else if (type == blockquoteAttribution) {
return _TextType.blockquote;
} else {
return _TextType.paragraph;
}
} else if (selectedNode is ListItemNode) {
return selectedNode.type == ListItemType.ordered
? _TextType.orderedListItem
: _TextType.unorderedListItem;
} else {
throw Exception('Invalid node type: $selectedNode');
}
}
/// Returns the text alignment of the currently selected text node.
///
/// Throws an exception if the currently selected node is not a text node.
TextAlign _getCurrentTextAlignment() {
final selectedNode = widget.editor.document
.getNodeById(widget.composer.selection.extent.nodeId);
if (selectedNode is ParagraphNode) {
final dynamic align = selectedNode.getMetadataValue('textAlign');
switch (align) {
case 'left':
return TextAlign.left;
case 'center':
return TextAlign.center;
case 'right':
return TextAlign.right;
case 'justify':
return TextAlign.justify;
default:
return TextAlign.left;
}
} else {
throw Exception(
'Alignment does not apply to node of type: $selectedNode');
}
}
/// Returns true if a single text node is selected and that text node
/// is capable of respecting alignment, returns false otherwise.
bool _isTextAlignable() {
final selection = widget.composer.selection;
if (selection.base.nodeId != selection.extent.nodeId) {
return false;
}
final selectedNode =
widget.editor.document.getNodeById(selection.extent.nodeId);
return selectedNode is ParagraphNode;
}
/// Converts the currently selected text node into a new type of
/// text node, represented by [newType].
///
/// For example: convert a paragraph to a blockquote, or a header
/// to a list item.
void _convertTextToNewType(_TextType newType) {
final existingTextType = _getCurrentTextType();
if (existingTextType == newType) {
// The text is already the desired type. Return.
return;
}
if (_isListItem(existingTextType) && _isListItem(newType)) {
widget.editor.executeCommand(
ChangeListItemTypeCommand(
nodeId: widget.composer.selection.extent.nodeId,
newType: newType == _TextType.orderedListItem
? ListItemType.ordered
: ListItemType.unordered,
),
);
} else if (_isListItem(existingTextType) && !_isListItem(newType)) {
widget.editor.executeCommand(
ConvertListItemToParagraphCommand(
nodeId: widget.composer.selection.extent.nodeId,
paragraphMetadata: <String, dynamic>{
'blockType': _getBlockTypeAttribution(newType),
},
),
);
} else if (!_isListItem(existingTextType) && _isListItem(newType)) {
widget.editor.executeCommand(
ConvertParagraphToListItemCommand(
nodeId: widget.composer.selection.extent.nodeId,
type: newType == _TextType.orderedListItem
? ListItemType.ordered
: ListItemType.unordered,
),
);
} else {
// Apply a new block type to an existing paragraph node.
final existingNode = widget.editor.document
.getNodeById(widget.composer.selection.extent.nodeId)
as ParagraphNode;
existingNode.putMetadataValue(
'blockType', _getBlockTypeAttribution(newType));
}
}
/// Returns true if the given [_TextType] represents an
/// ordered or unordered list item, returns false otherwise.
bool _isListItem(_TextType type) {
return type == _TextType.orderedListItem ||
type == _TextType.unorderedListItem;
}
/// Returns the text [Attribution] associated with the given
/// [_TextType], e.g., [_TextType.header1] -> [header1Attribution].
Attribution _getBlockTypeAttribution(_TextType newType) {
switch (newType) {
case _TextType.header1:
return header1Attribution;
case _TextType.header2:
return header2Attribution;
case _TextType.header3:
return header3Attribution;
case _TextType.blockquote:
return blockquoteAttribution;
case _TextType.paragraph:
default:
return null;
}
}
*/
/// Toggles bold styling for the current selected text.
void _toggleBold() {
widget.editor!.executeCommand(
ToggleTextAttributionsCommand(
documentSelection: widget.composer!.selection!,
attributions: {boldAttribution},
),
);
}
/// Toggles italic styling for the current selected text.
void _toggleItalics() {
widget.editor!.executeCommand(
ToggleTextAttributionsCommand(
documentSelection: widget.composer!.selection!,
attributions: {italicsAttribution},
),
);
}
/// Toggles strikethrough styling for the current selected text.
void _toggleStrikethrough() {
widget.editor!.executeCommand(
ToggleTextAttributionsCommand(
documentSelection: widget.composer!.selection!,
attributions: {strikethroughAttribution},
),
);
}
/// Returns true if the current text selection includes part
/// or all of a single link, returns false if zero links are
/// in the selection or if 2+ links are in the selection.
bool _isSingleLinkSelected() {
return _getSelectedLinkSpans().length == 1;
}
/// Returns true if the current text selection includes 2+
/// links, returns false otherwise.
bool _areMultipleLinksSelected() {
return _getSelectedLinkSpans().length >= 2;
}
/// Returns any link-based [AttributionSpan]s that appear partially
/// or wholly within the current text selection.
Set<AttributionSpan> _getSelectedLinkSpans() {
final selection = widget.composer!.selection!;
final baseOffset = (selection.base.nodePosition as TextPosition).offset;
final extentOffset = (selection.extent.nodePosition as TextPosition).offset;
final selectionStart = min(baseOffset, extentOffset);
final selectionEnd = max(baseOffset, extentOffset);
final selectionRange =
SpanRange(start: selectionStart, end: selectionEnd - 1);
final textNode = widget.editor!.document
.getNodeById(selection.extent.nodeId) as TextNode;
final text = textNode.text;
final overlappingLinkAttributions = text.getAttributionSpansInRange(
attributionFilter: (Attribution attribution) =>
attribution is LinkAttribution,
range: selectionRange,
);
return overlappingLinkAttributions;
}
/// Takes appropriate action when the toolbar's link button is
/// pressed.
void _onLinkPressed() {
final selection = widget.composer!.selection!;
final baseOffset = (selection.base.nodePosition as TextPosition).offset;
final extentOffset = (selection.extent.nodePosition as TextPosition).offset;
final selectionStart = min(baseOffset, extentOffset);
final selectionEnd = max(baseOffset, extentOffset);
final selectionRange =
SpanRange(start: selectionStart, end: selectionEnd - 1);
final textNode = widget.editor!.document
.getNodeById(selection.extent.nodeId) as TextNode;
final text = textNode.text;
final overlappingLinkAttributions = text.getAttributionSpansInRange(
attributionFilter: (Attribution attribution) =>
attribution is LinkAttribution,
range: selectionRange,
);
if (overlappingLinkAttributions.length >= 2) {
// Do nothing when multiple links are selected.
return;
}
if (overlappingLinkAttributions.isNotEmpty) {
// The selected text contains one other link.
final overlappingLinkSpan = overlappingLinkAttributions.first;
final isLinkSelectionOnTrailingEdge =
(overlappingLinkSpan.start >= selectionRange.start &&
overlappingLinkSpan.start <= selectionRange.end) ||
(overlappingLinkSpan.end >= selectionRange.start &&
overlappingLinkSpan.end <= selectionRange.end);
if (isLinkSelectionOnTrailingEdge) {
// The selected text covers the beginning, or the end, or the entire
// existing link. Remove the link attribution from the selected text.
text.removeAttribution(overlappingLinkSpan.attribution, selectionRange);
} else {
// The selected text sits somewhere within the existing link. Remove
// the entire link attribution.
text.removeAttribution(
overlappingLinkSpan.attribution,
SpanRange(
start: overlappingLinkSpan.start, end: overlappingLinkSpan.end),
);
}
} else {
// There are no other links in the selection. Show the URL text field.
setState(() {
_showUrlField = true;
_urlFocusNode!.requestFocus();
});
}
}
/// Takes the text from the [urlController] and applies it as a link
/// attribution to the currently selected text.
void _applyLink() {
final url = _urlController!.text.text;
final selection = widget.composer!.selection!;
final baseOffset = (selection.base.nodePosition as TextPosition).offset;
final extentOffset = (selection.extent.nodePosition as TextPosition).offset;
final selectionStart = min(baseOffset, extentOffset);
final selectionEnd = max(baseOffset, extentOffset);
final selectionRange =
TextRange(start: selectionStart, end: selectionEnd - 1);
final textNode = widget.editor!.document
.getNodeById(selection.extent.nodeId) as TextNode;
final text = textNode.text;
final trimmedRange = _trimTextRangeWhitespace(text, selectionRange);
final linkAttribution = LinkAttribution(url: Uri.parse(url));
text.addAttribution(
linkAttribution,
trimmedRange,
);
// Clear the field and hide the URL bar
_urlController!.clear();
setState(() {
_showUrlField = false;
_urlFocusNode!
.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
widget.closeToolbar();
});
}
/// Given [text] and a [range] within the [text], the [range] is
/// shortened on both sides to remove any trailing whitespace and
/// the new range is returned.
SpanRange _trimTextRangeWhitespace(AttributedText text, TextRange range) {
int startOffset = range.start;
int endOffset = range.end;
while (startOffset < range.end && text.text[startOffset] == ' ') {
startOffset += 1;
}
while (endOffset > startOffset && text.text[endOffset] == ' ') {
endOffset -= 1;
}
return SpanRange(start: startOffset, end: endOffset);
}
/*
/// Changes the alignment of the current selected text node
/// to reflect [newAlignment].
void _changeAlignment(TextAlign newAlignment) {
if (newAlignment == null) {
return;
}
String newAlignmentValue;
switch (newAlignment) {
case TextAlign.left:
case TextAlign.start:
newAlignmentValue = 'left';
break;
case TextAlign.center:
newAlignmentValue = 'center';
break;
case TextAlign.right:
case TextAlign.end:
newAlignmentValue = 'right';
break;
case TextAlign.justify:
newAlignmentValue = 'justify';
break;
}
final selectedNode = widget.editor.document
.getNodeById(widget.composer.selection.extent.nodeId) as ParagraphNode;
selectedNode.putMetadataValue('textAlign', newAlignmentValue);
}
/// Returns the localized name for the given [_TextType], e.g.,
/// "Paragraph" or "Header 1".
String _getTextTypeName(_TextType textType) {
switch (textType) {
case _TextType.header1:
return 'Header 1';
case _TextType.header2:
return 'Header 2';
case _TextType.header3:
return 'Header 3';
case _TextType.paragraph:
return 'Paragraph';
case _TextType.blockquote:
return 'Blockquote';
case _TextType.orderedListItem:
return 'Ordered List Item';
case _TextType.unorderedListItem:
return 'Unordered List Item';
}
return '';
}
*/
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Conditionally display the URL text field below
// the standard toolbar.
if (_showUrlField)
Positioned(
left: widget.anchor.value!.dx,
top: widget.anchor.value!.dy,
child: FractionalTranslation(
translation: const Offset(-0.5, 0.0),
child: _buildUrlField(),
),
),
_PositionedToolbar(
anchor: widget.anchor,
composer: widget.composer,
child: ValueListenableBuilder<DocumentSelection?>(
valueListenable: widget.composer!.selectionNotifier,
builder: (context, selection, child) {
if (selection == null) {
return const SizedBox();
}
if (selection.extent.nodePosition is! TextPosition) {
// The user selected non-text content. This toolbar is probably
// about to disappear. Until then, build nothing, because the
// toolbar needs to inspect selected text to build correctly.
return const SizedBox();
}
return _buildToolbar();
},
),
),
],
);
}
Widget _buildToolbar() {
return Material(
shape: const StadiumBorder(),
elevation: 5,
clipBehavior: Clip.hardEdge,
child: SizedBox(
height: 40,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
/*
// https://github.com/superlistapp/super_editor/issues/689
// https://github.com/flutter/flutter/issues/106923
// Only allow the user to select a new type of text node if
// the currently selected node can be converted.
if (_isConvertibleNode()) ...[
Tooltip(
message: 'Text Block Type',
child: DropdownButton<_TextType>(
value: _getCurrentTextType(),
items: _TextType.values
.map((textType) => DropdownMenuItem<_TextType>(
value: textType,
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(_getTextTypeName(textType)),
),
))
.toList(),
icon: const Icon(Icons.arrow_drop_down),
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
fontSize: 12,
),
underline: const SizedBox(),
elevation: 0,
itemHeight: 48,
onChanged: _convertTextToNewType,
),
),
_buildVerticalDivider(),
],
*/
Center(
child: IconButton(
onPressed: _toggleBold,
icon: const Icon(Icons.format_bold),
splashRadius: 16,
tooltip: 'Bold',
),
),
Center(
child: IconButton(
onPressed: _toggleItalics,
icon: const Icon(Icons.format_italic),
splashRadius: 16,
tooltip: 'Italics',
),
),
Center(
child: IconButton(
onPressed: _toggleStrikethrough,
icon: const Icon(Icons.strikethrough_s),
splashRadius: 16,
tooltip: 'Strikethrough',
),
),
Center(
child: IconButton(
onPressed: _areMultipleLinksSelected() ? null : _onLinkPressed,
icon: const Icon(Icons.link),
color: _isSingleLinkSelected()
? const Color(0xFF007AFF)
: IconTheme.of(context).color,
splashRadius: 16,
tooltip: 'Link',
),
),
// Only display alignment controls if the currently selected text
// node respects alignment. List items, for example, do not.
/*
if (_isTextAlignable()) ...[
_buildVerticalDivider(),
Tooltip(
message: 'Text Alignment',
child: DropdownButton<TextAlign>(
value: _getCurrentTextAlignment(),
items: [
TextAlign.left,
TextAlign.center,
TextAlign.right,
TextAlign.justify
]
.map((textAlign) => DropdownMenuItem<TextAlign>(
value: textAlign,
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(_buildTextAlignIcon(textAlign)),
),
))
.toList(),
icon: const Icon(Icons.arrow_drop_down),
style: const TextStyle(
color: Colors.black,
fontSize: 12,
),
underline: const SizedBox(),
elevation: 0,
itemHeight: 48,
onChanged: _changeAlignment,
),
),
],
_buildVerticalDivider(),
Center(
child: IconButton(
onPressed: () {},
icon: const Icon(Icons.more_vert),
splashRadius: 16,
tooltip: 'More Options',
),
),
*/
],
),
),
);
}
Widget _buildUrlField() {
return Material(
shape: const StadiumBorder(),
elevation: 5,
clipBehavior: Clip.hardEdge,
child: Container(
width: 400,
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: FocusWithCustomParent(
focusNode: _urlFocusNode,
parentFocusNode: widget.editorFocusNode,
// We use a SuperTextField instead of a TextField because TextField
// automatically re-parents its FocusNode, which causes #609. Flutter
// #106923 tracks the TextField issue.
child: SuperTextField(
focusNode: _urlFocusNode,
textController: _urlController,
minLines: 1,
maxLines: 1,
inputSource: TextInputSource.ime,
hintBehavior: HintBehavior.displayHintUntilTextEntered,
hintBuilder: (context) {
return Text(
'Enter a url...',
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
);
},
textStyleBuilder: (_) {
return TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
fontSize: 16,
);
},
),
),
),
IconButton(
icon: const Icon(Icons.close),
iconSize: 20,
splashRadius: 16,
padding: EdgeInsets.zero,
onPressed: () {
setState(() {
_urlFocusNode!.unfocus();
_showUrlField = false;
_urlController!.clear();
});
},
),
],
),
),
);
}
/*
Widget _buildVerticalDivider() {
return Container(
width: 1,
color: Colors.grey.shade300,
);
}
IconData _buildTextAlignIcon(TextAlign align) {
switch (align) {
case TextAlign.left:
case TextAlign.start:
return Icons.format_align_left;
case TextAlign.center:
return Icons.format_align_center;
case TextAlign.right:
case TextAlign.end:
return Icons.format_align_right;
case TextAlign.justify:
return Icons.format_align_justify;
}
return null;
}
*/
}
/*
enum _TextType {
header1,
header2,
header3,
paragraph,
blockquote,
orderedListItem,
unorderedListItem,
}
*/
/// Small toolbar that is intended to display over an image and
/// offer controls to expand or contract the size of the image.
///
/// [ImageFormatToolbar] expects to be displayed in a [Stack] where it
/// will position itself based on the given [anchor]. This can be
/// accomplished, for example, by adding [ImageFormatToolbar] to the
/// application [Overlay]. Any other [Stack] should work, too.
class ImageFormatToolbar extends StatefulWidget {
const ImageFormatToolbar({
Key? key,
required this.anchor,
required this.composer,
required this.setWidth,
required this.closeToolbar,
}) : super(key: key);
/// [ImageFormatToolbar] displays itself horizontally centered and
/// slightly above the given [anchor] value.
///
/// [anchor] is a [ValueNotifier] so that [ImageFormatToolbar] can
/// reposition itself as the [Offset] value changes.
final ValueNotifier<Offset?> anchor;
/// The [composer] provides access to the user's current
/// selection within the document, which dictates the
/// content that is altered by the toolbar's options.
final DocumentComposer? composer;
/// Callback that should update the width of the component with
/// the given [nodeId] to match the given [width].
final void Function(String nodeId, double? width) setWidth;
/// Delegate that instructs the owner of this [ImageFormatToolbar]
/// to close the toolbar.
final VoidCallback closeToolbar;
@override
_ImageFormatToolbarState createState() => _ImageFormatToolbarState();
}
class _ImageFormatToolbarState extends State<ImageFormatToolbar> {
void _makeImageConfined() {
widget.setWidth(widget.composer!.selection!.extent.nodeId, null);
}
void _makeImageFullBleed() {
widget.setWidth(widget.composer!.selection!.extent.nodeId, double.infinity);
}
@override
Widget build(BuildContext context) {
return _PositionedToolbar(
anchor: widget.anchor,
composer: widget.composer,
child: ValueListenableBuilder<DocumentSelection?>(
valueListenable: widget.composer!.selectionNotifier,
builder: (context, selection, child) {
if (selection == null) {
return const SizedBox();
}
if (selection.extent.nodePosition
is! UpstreamDownstreamNodePosition) {
// The user selected non-image content. This toolbar is probably
// about to disappear. Until then, build nothing, because the
// toolbar needs to inspect selected image to build correctly.
return const SizedBox();
}
return _buildToolbar();
},
),
);
}
Widget _buildToolbar() {
return Material(
shape: const StadiumBorder(),
elevation: 5,
clipBehavior: Clip.hardEdge,
child: SizedBox(
height: 40,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: IconButton(
onPressed: _makeImageConfined,
icon: const Icon(Icons.photo_size_select_large),
splashRadius: 16,
tooltip: 'Bold',
),
),
Center(
child: IconButton(
onPressed: _makeImageFullBleed,
icon: const Icon(Icons.photo_size_select_actual),
splashRadius: 16,
tooltip: 'Italics',
),
),
],
),
),
),
);
}
}
class _PositionedToolbar extends StatelessWidget {
const _PositionedToolbar({
Key? key,
required this.anchor,
required this.composer,
required this.child,
}) : super(key: key);
final ValueNotifier<Offset?> anchor;
final DocumentComposer? composer;
final Widget child;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Offset?>(
valueListenable: anchor,
builder: (context, offset, _) {
if (offset == null || composer!.selection == null) {
// When no anchor position is available, or the user hasn't
// selected any text, show nothing.
return const SizedBox();
}
return SizedBox.expand(
child: Stack(
children: [
Positioned(
left: offset.dx,
top: offset.dy,
child: FractionalTranslation(
translation: const Offset(-0.5, -1.4),
child: child,
),
),
],
),
);
},
);
}
}
class SingleLineAttributedTextEditingController
extends AttributedTextEditingController {
SingleLineAttributedTextEditingController(this.onSubmit);
final VoidCallback onSubmit;
@override
void insertNewline() {
// Don't insert newline in a single-line text field.
// Invoke callback to take action on enter.
onSubmit();
// TODO: this is a hack. SuperTextField shouldn't insert newlines in a single
// line field (#697).
}
}