import 'dart:math'; //import 'package:example/logging.dart'; import 'package:flutter/material.dart'; //import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:invoiceninja_flutter/utils/super_editor/super_editor_item_selector.dart'; import 'package:overlord/follow_the_leader.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.editorViewportKey, required this.editorFocusNode, required this.editor, required this.document, required this.composer, required this.anchor, required this.closeToolbar, }) : super(key: key); /// [GlobalKey] that should be attached to a widget that wraps the viewport /// area, which keeps the toolbar from appearing outside of the editor area. final GlobalKey editorViewportKey; /// A [LeaderLink] that should be attached to the boundary of the toolbar /// focal area, such as wrapped around the user's selection area. /// /// The toolbar is positioned relative to this anchor link. final LeaderLink 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 Editor? editor; final Document document; /// 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 State createState() => _EditorToolbarState(); } class _EditorToolbarState extends State { late final FollowerAligner _toolbarAligner; late FollowerBoundary _screenBoundary; bool _showUrlField = false; late FocusNode _popoverFocusNode; late FocusNode _urlFocusNode; ImeAttributedTextEditingController? _urlController; @override void initState() { super.initState(); _toolbarAligner = CupertinoPopoverToolbarAligner(widget.editorViewportKey); _popoverFocusNode = FocusNode(); _urlFocusNode = FocusNode(); _urlController = ImeAttributedTextEditingController( controller: SingleLineAttributedTextEditingController(_applyLink)) // ..onPerformActionPressed = _onPerformAction ..text = AttributedText('https://'); } @override void didChangeDependencies() { super.didChangeDependencies(); _screenBoundary = WidgetFollowerBoundary( boundaryKey: widget.editorViewportKey, devicePixelRatio: MediaQuery.devicePixelRatioOf(context), ); } @override void dispose() { _urlFocusNode.dispose(); _urlController!.dispose(); _popoverFocusNode.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.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.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final 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.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final 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.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!.execute([ ChangeListItemTypeRequest( nodeId: widget.composer.selection!.extent.nodeId, newType: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), ]); } else if (_isListItem(existingTextType) && !_isListItem(newType)) { widget.editor!.execute([ ConvertListItemToParagraphRequest( nodeId: widget.composer.selection!.extent.nodeId, paragraphMetadata: { 'blockType': _getBlockTypeAttribution(newType), }, ), ]); } else if (!_isListItem(existingTextType) && _isListItem(newType)) { widget.editor!.execute([ ConvertParagraphToListItemRequest( 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. widget.editor!.execute([ ChangeParagraphBlockTypeRequest( nodeId: widget.composer.selection!.extent.nodeId, 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!.execute([ ToggleTextAttributionsRequest( documentRange: widget.composer.selection!, attributions: {boldAttribution}, ), ]); } /// Toggles italic styling for the current selected text. void _toggleItalics() { widget.editor!.execute([ ToggleTextAttributionsRequest( documentRange: widget.composer.selection!, attributions: {italicsAttribution}, ), ]); } /// Toggles strikethrough styling for the current selected text. void _toggleStrikethrough() { widget.editor!.execute([ ToggleTextAttributionsRequest( documentRange: 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 _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(selectionStart, selectionEnd - 1); final textNode = widget.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(selectionStart, selectionEnd - 1); final textNode = widget.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(overlappingLinkSpan.start, 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.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(startOffset, 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.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 AppLocalizations.of(context)!.labelHeader1; return 'Header 1'; case _TextType.header2: // return AppLocalizations.of(context)!.labelHeader2; return 'Header 2'; case _TextType.header3: // return AppLocalizations.of(context)!.labelHeader3; return 'Header 3'; case _TextType.paragraph: // return AppLocalizations.of(context)!.labelParagraph; return 'Paragraph'; case _TextType.blockquote: // return AppLocalizations.of(context)!.labelBlockquote; return 'Blockquote'; case _TextType.orderedListItem: // return AppLocalizations.of(context)!.labelOrderedListItem; return 'Ordered List Item'; case _TextType.unorderedListItem: // return AppLocalizations.of(context)!.labelUnorderedListItem; return 'Unordered List Item'; } } /// Called when the user selects a block type on the toolbar. void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { if (selectedItem != null) { setState(() { _convertTextToNewType(_TextType.values // .where((e) => e.name == selectedItem.id) .first); }); } } void _onPerformAction(TextInputAction action) { if (action == TextInputAction.done) { _applyLink(); } } @override Widget build(BuildContext context) { return BuildInOrder( children: [ FollowerFadeOutBeyondBoundary( link: widget.anchor, boundary: _screenBoundary, child: Follower.withAligner( link: widget.anchor, aligner: _toolbarAligner, boundary: _screenBoundary, showWhenUnlinked: false, child: _buildToolbars(), ), ), ], ); } Widget _buildToolbars() { return SuperEditorPopover( popoverFocusNode: _popoverFocusNode, editorFocusNode: widget.editorFocusNode, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildToolbar(), if (_showUrlField) ...[ const SizedBox(height: 8), _buildUrlField(), ], ], ), ); } Widget _buildToolbar() { return IntrinsicWidth( child: Material( shape: const StadiumBorder(), elevation: 5, clipBehavior: Clip.hardEdge, child: SizedBox( height: 40, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Only allow the user to select a new type of text node if // the currently selected node can be converted. if (_isConvertibleNode()) ...[ Tooltip( //message: AppLocalizations.of(context)!.labelTextBlockType, message: 'Block Type', child: _buildBlockTypeSelector(), ), _buildVerticalDivider(), ], Center( child: IconButton( onPressed: _toggleBold, icon: const Icon(Icons.format_bold), splashRadius: 16, //tooltip: AppLocalizations.of(context)!.Bold, tooltip: 'Bold', ), ), Center( child: IconButton( onPressed: _toggleItalics, icon: const Icon(Icons.format_italic), splashRadius: 16, //tooltip: AppLocalizations.of(context)!.labelItalics, tooltip: 'Italics', ), ), Center( child: IconButton( onPressed: _toggleStrikethrough, icon: const Icon(Icons.strikethrough_s), splashRadius: 16, //tooltip: AppLocalizations.of(context)!.labelStrikethrough, 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: AppLocalizations.of(context)!.labelLink, 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: AppLocalizations.of(context)!.labelTextAlignment, message: 'Text Alignment', child: DropdownButton( value: _getCurrentTextAlignment(), items: [ TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify ] .map((textAlign) => DropdownMenuItem( 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: AppLocalizations.of(context)!.labelMoreOptions, tooltip: 'More Options', ), ), */ ], ), ), ), ); } Widget _buildBlockTypeSelector() { final currentBlockType = _getCurrentTextType(); return SuperEditorDemoTextItemSelector( parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, id: SuperEditorDemoTextItem( id: currentBlockType.name, label: _getTextTypeName(currentBlockType), ), items: _TextType.values .map( (blockType) => SuperEditorDemoTextItem( id: blockType.name, label: _getTextTypeName(blockType), ), ) .toList(), onSelected: _onBlockTypeSelected, ); } 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: Focus( focusNode: _urlFocusNode, parentNode: _popoverFocusNode, // 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 const Text( 'enter a url...', style: TextStyle( color: Colors.grey, fontSize: 16, ), ); }, textStyleBuilder: (_) { return const TextStyle( color: 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; } } */ } 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 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 State createState() => _ImageFormatToolbarState(); } class _ImageFormatToolbarState extends State { 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( valueListenable: widget.composer.selectionNotifier, builder: (context, selection, child) { //appLog.fine("Building image toolbar. Selection: $selection"); 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: AppLocalizations.of(context)!.labelBold, tooltip: 'Bold', ), ), Center( child: IconButton( onPressed: _makeImageFullBleed, icon: const Icon(Icons.photo_size_select_actual), splashRadius: 16, //tooltip: AppLocalizations.of(context)!.labelItalics, tooltip: 'Italics', ), ), ], ), ), ), ); } } class _PositionedToolbar extends StatelessWidget { const _PositionedToolbar({ Key? key, required this.anchor, required this.composer, required this.child, }) : super(key: key); final ValueNotifier anchor; final DocumentComposer composer; final Widget child; @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: anchor, builder: (context, offset, _) { //appLog.fine( // "(Re)Building _PositionedToolbar widget due to anchor change"); if (offset == null || composer.selection == null) { //appLog.fine("Anchor is null. Building an empty box."); // When no anchor position is available, or the user hasn't // selected any text, show nothing. return const SizedBox(); } //appLog.fine("Anchor is non-null: $offset, child: $child"); 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). } }