mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: implement link menu
This commit is contained in:
parent
f943aeacd7
commit
1f90f30274
@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 4.3999H4.11111H13" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.88867 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.11133 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 883 B |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -1,4 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@ -6,6 +6,31 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
|
||||
|
||||
extension TextNodeExtension on TextNode {
|
||||
dynamic getAttributeInSelection(Selection selection, String styleKey) {
|
||||
final ops = delta.whereType<TextInsert>();
|
||||
final startOffset =
|
||||
selection.isBackward ? selection.start.offset : selection.end.offset;
|
||||
final endOffset =
|
||||
selection.isBackward ? selection.end.offset : selection.start.offset;
|
||||
var start = 0;
|
||||
for (final op in ops) {
|
||||
if (start >= endOffset) {
|
||||
break;
|
||||
}
|
||||
final length = op.length;
|
||||
if (start < endOffset && start + length > startOffset) {
|
||||
if (op.attributes?.containsKey(styleKey) == true) {
|
||||
return op.attributes![styleKey];
|
||||
}
|
||||
}
|
||||
start += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool allSatisfyLinkInSelection(Selection selection) =>
|
||||
allSatisfyInSelection(StyleKey.href, null, selection);
|
||||
|
||||
bool allSatisfyBoldInSelection(Selection selection) =>
|
||||
allSatisfyInSelection(StyleKey.bold, true, selection);
|
||||
|
||||
|
@ -0,0 +1,137 @@
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LinkMenu extends StatefulWidget {
|
||||
const LinkMenu({
|
||||
Key? key,
|
||||
this.linkText,
|
||||
required this.onSubmitted,
|
||||
required this.onCopyLink,
|
||||
required this.onRemoveLink,
|
||||
}) : super(key: key);
|
||||
|
||||
final String? linkText;
|
||||
final void Function(String text) onSubmitted;
|
||||
final VoidCallback onCopyLink;
|
||||
final VoidCallback onRemoveLink;
|
||||
|
||||
@override
|
||||
State<LinkMenu> createState() => _LinkMenuState();
|
||||
}
|
||||
|
||||
class _LinkMenuState extends State<LinkMenu> {
|
||||
final _textEditingController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textEditingController.text = widget.linkText ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 350,
|
||||
height: 200,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 350,
|
||||
height: 200,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 16.0),
|
||||
_buildInput(),
|
||||
const SizedBox(height: 16.0),
|
||||
_buildIconButton(
|
||||
iconName: 'link',
|
||||
text: 'Copy link',
|
||||
onPressed: widget.onCopyLink,
|
||||
),
|
||||
_buildIconButton(
|
||||
iconName: 'delete',
|
||||
text: 'Remove link',
|
||||
onPressed: widget.onRemoveLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return const Text(
|
||||
'Add your link',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput() {
|
||||
return TextField(
|
||||
autofocus: true,
|
||||
style: const TextStyle(fontSize: 14.0),
|
||||
textAlign: TextAlign.left,
|
||||
controller: _textEditingController,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'URL',
|
||||
hintStyle: TextStyle(fontSize: 14.0),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12.0)),
|
||||
borderSide: BorderSide(color: Color(0xFFBDBDBD)),
|
||||
),
|
||||
contentPadding: EdgeInsets.all(16.0),
|
||||
isDense: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconButton({
|
||||
required String iconName,
|
||||
required String text,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return TextButton.icon(
|
||||
icon: FlowySvg(
|
||||
name: iconName,
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(40),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
label: Text(
|
||||
text,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,217 +0,0 @@
|
||||
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||
|
||||
typedef ToolbarEventHandler = void Function(EditorState editorState);
|
||||
|
||||
typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
|
||||
|
||||
ToolbarEventHandlers defaultToolbarEventHandlers = {
|
||||
'bold': (editorState) => formatBold(editorState),
|
||||
'italic': (editorState) => formatItalic(editorState),
|
||||
'strikethrough': (editorState) => formatStrikethrough(editorState),
|
||||
'underline': (editorState) => formatUnderline(editorState),
|
||||
'quote': (editorState) => formatQuote(editorState),
|
||||
'bulleted_list': (editorState) => formatBulletedList(editorState),
|
||||
'highlight': (editorState) => formatHighlight(editorState),
|
||||
'Text': (editorState) => formatText(editorState),
|
||||
'h1': (editorState) => formatHeading(editorState, StyleKey.h1),
|
||||
'h2': (editorState) => formatHeading(editorState, StyleKey.h2),
|
||||
'h3': (editorState) => formatHeading(editorState, StyleKey.h3),
|
||||
};
|
||||
|
||||
List<String> defaultListToolbarEventNames = [
|
||||
'Text',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
];
|
||||
|
||||
mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
|
||||
void hide();
|
||||
}
|
||||
|
||||
class ToolbarWidget extends StatefulWidget {
|
||||
const ToolbarWidget({
|
||||
Key? key,
|
||||
required this.editorState,
|
||||
required this.layerLink,
|
||||
required this.offset,
|
||||
required this.handlers,
|
||||
}) : super(key: key);
|
||||
|
||||
final EditorState editorState;
|
||||
final LayerLink layerLink;
|
||||
final Offset offset;
|
||||
final ToolbarEventHandlers handlers;
|
||||
|
||||
@override
|
||||
State<ToolbarWidget> createState() => _ToolbarWidgetState();
|
||||
}
|
||||
|
||||
class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
||||
// final GlobalKey _listToolbarKey = GlobalKey();
|
||||
|
||||
final toolbarHeight = 32.0;
|
||||
final topPadding = 5.0;
|
||||
|
||||
final listToolbarWidth = 60.0;
|
||||
final listToolbarHeight = 120.0;
|
||||
|
||||
final cornerRadius = 8.0;
|
||||
|
||||
OverlayEntry? _listToolbarOverlay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
top: widget.offset.dx,
|
||||
left: widget.offset.dy,
|
||||
child: CompositedTransformFollower(
|
||||
link: widget.layerLink,
|
||||
showWhenUnlinked: true,
|
||||
offset: widget.offset,
|
||||
child: _buildToolbar(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void hide() {
|
||||
_listToolbarOverlay?.remove();
|
||||
_listToolbarOverlay = null;
|
||||
}
|
||||
|
||||
Widget _buildToolbar(BuildContext context) {
|
||||
return Material(
|
||||
borderRadius: BorderRadius.circular(cornerRadius),
|
||||
color: const Color(0xFF333333),
|
||||
child: SizedBox(
|
||||
height: toolbarHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// _listToolbar(context),
|
||||
_centerToolbarIcon('h1', tooltipMessage: 'Heading 1'),
|
||||
_centerToolbarIcon('h2', tooltipMessage: 'Heading 2'),
|
||||
_centerToolbarIcon('h3', tooltipMessage: 'Heading 3'),
|
||||
_centerToolbarIcon('divider', width: 2),
|
||||
_centerToolbarIcon('bold', tooltipMessage: 'Bold'),
|
||||
_centerToolbarIcon('italic', tooltipMessage: 'Italic'),
|
||||
_centerToolbarIcon('strikethrough',
|
||||
tooltipMessage: 'Strikethrough'),
|
||||
_centerToolbarIcon('underline', tooltipMessage: 'Underline'),
|
||||
_centerToolbarIcon('divider', width: 2),
|
||||
_centerToolbarIcon('quote', tooltipMessage: 'Quote'),
|
||||
// _centerToolbarIcon('number_list'),
|
||||
_centerToolbarIcon('bulleted_list',
|
||||
tooltipMessage: 'Bulleted List'),
|
||||
_centerToolbarIcon('divider', width: 2),
|
||||
_centerToolbarIcon('highlight', tooltipMessage: 'Highlight'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Widget _listToolbar(BuildContext context) {
|
||||
// return _centerToolbarIcon(
|
||||
// 'quote',
|
||||
// key: _listToolbarKey,
|
||||
// width: listToolbarWidth,
|
||||
// onTap: () => _onTapListToolbar(context),
|
||||
// );
|
||||
// }
|
||||
|
||||
Widget _centerToolbarIcon(String name,
|
||||
{Key? key, String? tooltipMessage, double? width, VoidCallback? onTap}) {
|
||||
return Tooltip(
|
||||
key: key,
|
||||
preferBelow: false,
|
||||
message: tooltipMessage ?? '',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () => _onTap(name),
|
||||
child: SizedBox.fromSize(
|
||||
size:
|
||||
Size(toolbarHeight - (width != null ? 20 : 0), toolbarHeight),
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
width: width ?? 20,
|
||||
name: 'toolbar/$name',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// void _onTapListToolbar(BuildContext context) {
|
||||
// // TODO: implement more detailed UI.
|
||||
// final items = defaultListToolbarEventNames;
|
||||
// final renderBox =
|
||||
// _listToolbarKey.currentContext?.findRenderObject() as RenderBox;
|
||||
// final offset = renderBox
|
||||
// .localToGlobal(Offset.zero)
|
||||
// .translate(0, toolbarHeight - cornerRadius);
|
||||
// final rect = offset & Size(listToolbarWidth, listToolbarHeight);
|
||||
|
||||
// _listToolbarOverlay?.remove();
|
||||
// _listToolbarOverlay = OverlayEntry(builder: (context) {
|
||||
// return Positioned.fromRect(
|
||||
// rect: rect,
|
||||
// child: Material(
|
||||
// borderRadius: BorderRadius.only(
|
||||
// bottomLeft: Radius.circular(cornerRadius),
|
||||
// bottomRight: Radius.circular(cornerRadius),
|
||||
// ),
|
||||
// color: const Color(0xFF333333),
|
||||
// child: SingleChildScrollView(
|
||||
// child: ListView.builder(
|
||||
// itemExtent: toolbarHeight,
|
||||
// padding: const EdgeInsets.only(bottom: 10.0),
|
||||
// shrinkWrap: true,
|
||||
// itemCount: items.length,
|
||||
// itemBuilder: ((context, index) {
|
||||
// return ListTile(
|
||||
// contentPadding: const EdgeInsets.only(
|
||||
// left: 3.0,
|
||||
// right: 3.0,
|
||||
// ),
|
||||
// minVerticalPadding: 0.0,
|
||||
// title: FittedBox(
|
||||
// fit: BoxFit.scaleDown,
|
||||
// child: Text(
|
||||
// items[index],
|
||||
// textAlign: TextAlign.center,
|
||||
// style: const TextStyle(
|
||||
// color: Colors.white,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// onTap: () {
|
||||
// _onTap(items[index]);
|
||||
// },
|
||||
// );
|
||||
// }),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// });
|
||||
// // TODO: disable scrolling.
|
||||
// Overlay.of(context)?.insert(_listToolbarOverlay!);
|
||||
// }
|
||||
|
||||
void _onTap(String eventName) {
|
||||
if (defaultToolbarEventHandlers.containsKey(eventName)) {
|
||||
defaultToolbarEventHandlers[eventName]!(widget.editorState);
|
||||
return;
|
||||
}
|
||||
assert(false, 'Could not find the event handler for $eventName');
|
||||
}
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
|
||||
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
|
||||
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rich_clipboard/rich_clipboard.dart';
|
||||
|
||||
typedef ToolbarEventHandler = void Function(
|
||||
EditorState editorState, BuildContext context);
|
||||
typedef ToolbarShowValidator = bool Function(EditorState editorState);
|
||||
|
||||
class ToolbarItem {
|
||||
ToolbarItem({
|
||||
required this.icon,
|
||||
this.tooltipsMessage = '',
|
||||
required this.validator,
|
||||
required this.handler,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final String tooltipsMessage;
|
||||
final ToolbarShowValidator validator;
|
||||
final ToolbarEventHandler handler;
|
||||
|
||||
factory ToolbarItem.divider() {
|
||||
return ToolbarItem(
|
||||
icon: const FlowySvg(name: 'toolbar/divider'),
|
||||
validator: (editorState) => true,
|
||||
handler: (editorState, context) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ToolbarItem> defaultToolbarItems = [
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Heading 1',
|
||||
icon: const FlowySvg(name: 'toolbar/h1'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatHeading(editorState, StyleKey.h1),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Heading 2',
|
||||
icon: const FlowySvg(name: 'toolbar/h2'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatHeading(editorState, StyleKey.h2),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Heading 3',
|
||||
icon: const FlowySvg(name: 'toolbar/h3'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
|
||||
),
|
||||
ToolbarItem.divider(),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Bold',
|
||||
icon: const FlowySvg(name: 'toolbar/bold'),
|
||||
validator: _showInTextSelection,
|
||||
handler: (editorState, context) => formatBold(editorState),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Italic',
|
||||
icon: const FlowySvg(name: 'toolbar/italic'),
|
||||
validator: _showInTextSelection,
|
||||
handler: (editorState, context) => formatItalic(editorState),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Underline',
|
||||
icon: const FlowySvg(name: 'toolbar/underline'),
|
||||
validator: _showInTextSelection,
|
||||
handler: (editorState, context) => formatUnderline(editorState),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Strikethrough',
|
||||
icon: const FlowySvg(name: 'toolbar/strikethrough'),
|
||||
validator: _showInTextSelection,
|
||||
handler: (editorState, context) => formatStrikethrough(editorState),
|
||||
),
|
||||
ToolbarItem.divider(),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Quote',
|
||||
icon: const FlowySvg(name: 'toolbar/quote'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatQuote(editorState),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Bulleted list',
|
||||
icon: const FlowySvg(name: 'toolbar/bulleted_list'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatBulletedList(editorState),
|
||||
),
|
||||
ToolbarItem.divider(),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Link',
|
||||
icon: const FlowySvg(name: 'toolbar/link'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => _showLinkMenu(editorState, context),
|
||||
),
|
||||
ToolbarItem(
|
||||
tooltipsMessage: 'Highlight',
|
||||
icon: const FlowySvg(name: 'toolbar/highlight'),
|
||||
validator: _showInTextSelection,
|
||||
handler: (editorState, context) => formatHighlight(editorState),
|
||||
),
|
||||
];
|
||||
|
||||
ToolbarShowValidator _onlyShowInSingleTextSelection = (editorState) {
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
return (nodes.length == 1 && nodes.first is TextNode);
|
||||
};
|
||||
|
||||
ToolbarShowValidator _showInTextSelection = (editorState) {
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
return nodes.isNotEmpty;
|
||||
};
|
||||
|
||||
OverlayEntry? _linkMenuOverlay;
|
||||
EditorState? _editorState;
|
||||
void _showLinkMenu(EditorState editorState, BuildContext context) {
|
||||
_editorState = editorState;
|
||||
|
||||
final rects = editorState.service.selectionService.selectionRects;
|
||||
var maxBottom = 0.0;
|
||||
late Rect matchRect;
|
||||
for (final rect in rects) {
|
||||
if (rect.bottom > maxBottom) {
|
||||
maxBottom = rect.bottom;
|
||||
matchRect = rect;
|
||||
}
|
||||
}
|
||||
|
||||
_dismissLinkMenu();
|
||||
|
||||
// Since the link menu will only show in single text selection,
|
||||
// We get the text node directly instead of judging details again.
|
||||
final selection =
|
||||
editorState.service.selectionService.currentSelection.value!;
|
||||
final index =
|
||||
selection.isBackward ? selection.start.offset : selection.end.offset;
|
||||
final length = (selection.start.offset - selection.end.offset).abs();
|
||||
final node = editorState.service.selectionService.currentSelectedNodes.first
|
||||
as TextNode;
|
||||
final linkText = node.getAttributeInSelection(selection, StyleKey.href);
|
||||
_linkMenuOverlay = OverlayEntry(builder: (context) {
|
||||
return Positioned(
|
||||
top: matchRect.bottom,
|
||||
left: matchRect.left,
|
||||
child: Material(
|
||||
child: LinkMenu(
|
||||
linkText: linkText,
|
||||
onSubmitted: (text) {
|
||||
TransactionBuilder(editorState)
|
||||
..formatText(node, index, length, {
|
||||
StyleKey.href: text,
|
||||
})
|
||||
..commit();
|
||||
_dismissLinkMenu();
|
||||
},
|
||||
onCopyLink: () {
|
||||
RichClipboard.setData(RichClipboardData(text: linkText));
|
||||
_dismissLinkMenu();
|
||||
},
|
||||
onRemoveLink: () {
|
||||
TransactionBuilder(editorState)
|
||||
..formatText(node, index, length, {
|
||||
StyleKey.href: null,
|
||||
})
|
||||
..commit();
|
||||
_dismissLinkMenu();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
Overlay.of(context)?.insert(_linkMenuOverlay!);
|
||||
|
||||
editorState.service.scrollService?.disable();
|
||||
editorState.service.selectionService.currentSelection
|
||||
.addListener(_dismissLinkMenu);
|
||||
}
|
||||
|
||||
void _dismissLinkMenu() {
|
||||
_linkMenuOverlay?.remove();
|
||||
_linkMenuOverlay = null;
|
||||
|
||||
_editorState?.service.scrollService?.enable();
|
||||
_editorState?.service.selectionService.currentSelection
|
||||
.removeListener(_dismissLinkMenu);
|
||||
_editorState = null;
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'toolbar_item.dart';
|
||||
|
||||
class ToolbarItemWidget extends StatelessWidget {
|
||||
const ToolbarItemWidget({
|
||||
Key? key,
|
||||
required this.item,
|
||||
required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
final ToolbarItem item;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Tooltip(
|
||||
preferBelow: false,
|
||||
message: item.tooltipsMessage,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: item.icon,
|
||||
iconSize: 28,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
|
||||
import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
|
||||
mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
|
||||
void hide();
|
||||
}
|
||||
|
||||
class ToolbarWidget extends StatefulWidget {
|
||||
const ToolbarWidget({
|
||||
Key? key,
|
||||
required this.editorState,
|
||||
required this.layerLink,
|
||||
required this.offset,
|
||||
required this.items,
|
||||
}) : super(key: key);
|
||||
|
||||
final EditorState editorState;
|
||||
final LayerLink layerLink;
|
||||
final Offset offset;
|
||||
final List<ToolbarItem> items;
|
||||
|
||||
@override
|
||||
State<ToolbarWidget> createState() => _ToolbarWidgetState();
|
||||
}
|
||||
|
||||
class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
||||
OverlayEntry? _listToolbarOverlay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
top: widget.offset.dx,
|
||||
left: widget.offset.dy,
|
||||
child: CompositedTransformFollower(
|
||||
link: widget.layerLink,
|
||||
showWhenUnlinked: true,
|
||||
offset: widget.offset,
|
||||
child: _buildToolbar(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void hide() {
|
||||
_listToolbarOverlay?.remove();
|
||||
_listToolbarOverlay = null;
|
||||
}
|
||||
|
||||
Widget _buildToolbar(BuildContext context) {
|
||||
final items = widget.items.where(
|
||||
(item) => item.validator(widget.editorState),
|
||||
);
|
||||
return Material(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
color: const Color(0xFF333333),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: SizedBox(
|
||||
height: 32.0,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items
|
||||
.map(
|
||||
(item) => Center(
|
||||
child: ToolbarItemWidget(
|
||||
item: item,
|
||||
onPressed: () {
|
||||
item.handler(widget.editorState, context);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -87,7 +87,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
||||
|
||||
@override
|
||||
void attach(TextEditingValue textEditingValue) {
|
||||
_textInputConnection ??= TextInput.attach(
|
||||
if (_textInputConnection == null ||
|
||||
_textInputConnection!.attached == false) {
|
||||
_textInputConnection = TextInput.attach(
|
||||
this,
|
||||
const TextInputConfiguration(
|
||||
// TODO: customize
|
||||
@ -96,6 +98,7 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_textInputConnection!
|
||||
..setEditingState(textEditingValue)
|
||||
|
@ -1,7 +1,8 @@
|
||||
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/render/selection/toolbar_widget.dart';
|
||||
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
|
||||
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
|
||||
|
||||
abstract class FlowyToolbarService {
|
||||
@ -41,7 +42,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||
editorState: widget.editorState,
|
||||
layerLink: layerLink,
|
||||
offset: offset.translate(0, -37.0),
|
||||
handlers: const {},
|
||||
items: defaultToolbarItems,
|
||||
),
|
||||
);
|
||||
Overlay.of(context)?.insert(_toolbarOverlay!);
|
||||
|
Loading…
Reference in New Issue
Block a user