feat: mobile tool bar basic features (#161)

* refactor: refactor mobile toolbar

* feat: add text decoration icon in AFMobileIcons

* chore: update mobile parameter

* feat: add text decoration basic feature

* chore: replace text by AppFlowyEditorLocalizations

* feat: add code toolbar item

* chore: refactor code
- add hasMenu in MToolbarItem
- add const size

* feat: add line input menu

* chore: create toolbar items draft file

* refactor: textDecorationMToolbarItem refactor

* fix: add key to ensures MToolbarWidget is rebuilt whenever the selection changes

* feat: add link toolbar item and the way to close item menu

* chore: replace m by mobile

* chore:replace BuiltInAttributeKey by FlowyRichTextKeys

* chore: replace m by mobile

* refactor: refactor MobileToolbarWidget

* refactor: add MobileToolbarStyle to control all cosmetic parameters

* refactor: refactor MobileToolbarItemMenuBtn to stateless widget

* feat: add heading menu

* fix: fix open the wrong menu

* feat: add heading icon

* feat: add todo list and list toolbar item

* feat: add quote toolbar item

* fix: fix selection service error

* test: add mobile toolbar basic elements test

* test: add item menu button test and helper widgets

* test: add text decoration mobile toolbar item test

* test: add code mobile toolbar item test

* test: add heading mobile toolbar item test

* test: add list mobile toolbar item test

* chore: fix typo

* test: add quote mobile toolbar item test

* test: add todo list mobile toolbar item test

* test: add link mobile toolbar item test

* chore: update name and add Material widget

* fix: flutter analyze

* fix: flutter analyze in test mobile toolbar

* chore: dart format test code

* refactor: improve code

* fix: fix refactor error

* chore: move all the tests in mobile to test/mobile

* chore: move toolbar/items to toolbar/desktop/items

* chore: remove the mobileToolbarItemMenuStateKey

* chore: add some comments

* test: update testing

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Yijing Huang 2023-06-13 01:01:08 -05:00 • committed by GitHub
commit 6a4a8ae818
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1574 additions and 125 deletions

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 4C7.4477 4 7 4.448 7 5V19C7 19.552 7.4477 20 8 20C8.5523 20 9 19.552 9 19V13H15V19C15 19.552 15.4477 20 16 20C16.5523 20 17 19.552 17 19V5C17 4.448 16.5523 4 16 4C15.4477 4 15 4.448 15 5V11H9V5C9 4.448 8.5523 4 8 4Z" fill="#2F3030"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.99902 6.10352C2.44702 6.10352 1.99902 6.55152 1.99902 7.10352C1.99902 7.65552 2.44702 8.10352 2.99902 8.10352H20.999C21.551 8.10352 21.999 7.65552 21.999 7.10352C21.999 6.55152 21.551 6.10352 20.999 6.10352H2.99902ZM2.99902 11.1035C2.44702 11.1035 1.99902 11.5515 1.99902 12.1035C1.99902 12.6555 2.44702 13.1035 2.99902 13.1035H20.999C21.551 13.1035 21.999 12.6555 21.999 12.1035C21.999 11.5515 21.551 11.1035 20.999 11.1035H2.99902ZM2.99902 16.1035C2.44702 16.1035 1.99902 16.5515 1.99902 17.1035C1.99902 17.6555 2.44702 18.1035 2.99902 18.1035H20.999C21.551 18.1035 21.999 17.6555 21.999 17.1035C21.999 16.5515 21.551 16.1035 20.999 16.1035H2.99902Z" fill="#2F3030"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View file

@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View file

@ -74,6 +74,9 @@ class _HomePageState extends State<HomePage> {
drawer: _buildDrawer(context),
body: SafeArea(child: _buildBody(context)),
floatingActionButton: _buildFloatingActionButton(context),
floatingActionButtonLocation: PlatformExtension.isMobile
? FloatingActionButtonLocation.startTop
: FloatingActionButtonLocation.endFloat,
);
}

View file

@ -1,9 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/infra/mobile/mobile.dart';
class SimpleEditor extends StatelessWidget {
const SimpleEditor({
@ -57,7 +55,7 @@ class SimpleEditor extends StatelessWidget {
scrollController,
),
);
} else {
} else if (PlatformExtension.isMobile) {
return Column(
children: [
Expanded(
@ -67,16 +65,26 @@ class SimpleEditor extends StatelessWidget {
scrollController,
),
),
if (Platform.isIOS || Platform.isAndroid)
_buildMobileToolbar(context, editorState),
MobileToolbar(
editorState: editorState,
toolbarItems: [
textDecorationMobileToolbarItem,
headingMobileToolbarItem,
todoListMobileToolbarItem,
listMobileToolbarItem,
linkMobileToolbarItem,
quoteMobileToolbarItem,
codeMobileToolbarItem,
// dividerMobileToolbarItem,
],
),
],
);
}
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
);
}
@ -103,29 +111,4 @@ class SimpleEditor extends StatelessWidget {
scrollController: scrollController,
);
}
Widget _buildMobileToolbar(BuildContext context, EditorState editorState) {
return MobileToolbar(
editorState: editorState,
// TODO(yijing): Implement toolbar features later
toolbarItems: [
IconButton(
onPressed: () {
editorState.selectionService.updateSelection(null);
},
icon: const Icon(Icons.keyboard_hide),
),
...AFMobileIcons.values
.map(
(e) => IconButton(
onPressed: () {},
icon: AFMobileIcon(
afMobileIcons: e,
),
),
)
.toList(),
],
);
}
}

View file

@ -66,3 +66,4 @@ export 'src/core/document/deprecated/document.dart';
export 'src/plugins/html/html_document_decoder.dart';
export 'src/plugins/html/html_document_encoder.dart';
export 'src/plugins/html/html_document.dart';
export 'src/infra/mobile/mobile.dart';

View file

@ -13,7 +13,7 @@ export 'service/scroll_service_widget.dart';
export 'service/selection_service_widget.dart';
// toolbar
export '../toolbar/mobile_toolbar.dart';
export '../toolbar/mobile/mobile.dart';
export '../toolbar/desktop/floating_toolbar.dart';
// renderer

View file

@ -0,0 +1,14 @@
export 'floating_toolbar.dart';
export 'floating_toolbar_widget.dart';
export 'items/bulleted_list_toolbar_item.dart';
export 'items/color/color.dart';
export 'items/format_toolbar_items.dart';
export 'items/heading_toolbar_items.dart';
export 'items/highlight_toolbar_item.dart';
export 'items/icon_item_widget.dart';
export 'items/link/link_toolbar_item.dart';
export 'items/numbered_list_toolbar_item.dart';
export 'items/paragraph_toolbar_item.dart';
export 'items/placeholder_toolbar_item.dart';
export 'items/quote_toolbar_item.dart';

View file

@ -0,0 +1,23 @@
import 'package:appflowy_editor/appflowy_editor.dart';
final ToolbarItem bulletedListItem = ToolbarItem(
id: 'editor.bulleted_list',
group: 3,
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'bulleted_list';
return IconItemWidget(
iconName: 'toolbar/bulleted_list',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.bulletedList,
onPressed: () => editorState.formatNode(
selection,
(node) => node.copyWith(
type: isHighlight ? 'paragraph' : 'bulleted_list',
),
),
);
},
);

View file

@ -0,0 +1,4 @@
export 'highlight_color_toolbar_item.dart';
export 'text_color_toolbar_item.dart';
export 'color_picker.dart';
export 'color_menu.dart';

View file

@ -0,0 +1,154 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
void showColorMenu(
BuildContext context,
EditorState editorState,
Selection selection, {
String? currentColorHex,
required bool isTextColor,
}) {
// Since link format is only available for single line selection,
// the first rect(also the only rect) is used as the starting reference point for the [overlay] position
final rect = editorState.selectionRects().first;
OverlayEntry? overlay;
// should abstract this logic to a method
// ----
final left = rect.left + 10;
double? top;
double? bottom;
final offset = rect.center;
final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero);
final editorHeight = editorState.renderBox!.size.height;
final threshold = editorOffset.dy + editorHeight - 200;
if (offset.dy > threshold) {
bottom = editorOffset.dy + editorHeight - rect.top - 5;
} else {
top = rect.bottom + 5;
}
// ----
void dismissOverlay() {
overlay?.remove();
overlay = null;
}
overlay = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: left,
builder: (context) {
return ColorPicker(
isTextColor: isTextColor,
editorState: editorState,
selectedColorHex: currentColorHex,
colorOptions: isTextColor
? _generateTextColorOptions(editorState)
: _generateHighlightColorOptions(editorState),
onSubmittedColorHex: (color) {
isTextColor
? _formatFontColor(
editorState,
color,
)
: _formatHighlightColor(
editorState,
color,
);
dismissOverlay();
},
onDismiss: dismissOverlay,
);
},
).build();
Overlay.of(context).insert(overlay!);
}
void _formatHighlightColor(EditorState editorState, String color) {
editorState.formatDelta(
editorState.selection,
{FlowyRichTextKeys.highlightColor: color},
);
}
void _formatFontColor(EditorState editorState, String color) {
editorState.formatDelta(
editorState.selection,
{FlowyRichTextKeys.textColor: color},
);
}
List<ColorOption> _generateTextColorOptions(EditorState editorState) {
return [
ColorOption(
colorHex: Colors.grey.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorGray,
),
ColorOption(
colorHex: Colors.brown.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorBrown,
),
ColorOption(
colorHex: Colors.yellow.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorYellow,
),
ColorOption(
colorHex: Colors.green.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorGreen,
),
ColorOption(
colorHex: Colors.blue.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorBlue,
),
ColorOption(
colorHex: Colors.purple.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorPurple,
),
ColorOption(
colorHex: Colors.pink.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorPink,
),
ColorOption(
colorHex: Colors.red.toHex(),
name: AppFlowyEditorLocalizations.current.fontColorRed,
),
];
}
List<ColorOption> _generateHighlightColorOptions(EditorState editorState) {
return [
ColorOption(
colorHex: Colors.grey.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorGray,
),
ColorOption(
colorHex: Colors.brown.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorBrown,
),
ColorOption(
colorHex: Colors.yellow.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorYellow,
),
ColorOption(
colorHex: Colors.green.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorGreen,
),
ColorOption(
colorHex: Colors.blue.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorBlue,
),
ColorOption(
colorHex: Colors.purple.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorPurple,
),
ColorOption(
colorHex: Colors.pink.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorPink,
),
ColorOption(
colorHex: Colors.red.withOpacity(0.3).toHex(),
name: AppFlowyEditorLocalizations.current.backgroundColorRed,
),
];
}

View file

@ -0,0 +1,408 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/overlay_util.dart';
import 'package:flutter/material.dart';
class ColorOption {
const ColorOption({
required this.colorHex,
required this.name,
});
// 0xFF000000
final String colorHex;
final String name;
}
class ColorPicker extends StatefulWidget {
const ColorPicker({
super.key,
required this.editorState,
required this.isTextColor,
required this.selectedColorHex,
required this.onSubmittedColorHex,
required this.colorOptions,
required this.onDismiss,
});
final bool isTextColor;
final EditorState editorState;
final String? selectedColorHex;
final void Function(String color) onSubmittedColorHex;
final Function() onDismiss;
final List<ColorOption> colorOptions;
@override
State<ColorPicker> createState() => _ColorPickerState();
}
class _ColorPickerState extends State<ColorPicker> {
final TextEditingController _colorHexController = TextEditingController();
final TextEditingController _colorOpacityController = TextEditingController();
@override
void initState() {
super.initState();
_colorHexController.text =
_extractColorHex(widget.selectedColorHex) ?? 'FFFFFF';
_colorOpacityController.text =
_convertHexToOpacity(widget.selectedColorHex) ?? '100';
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
height: 250,
decoration: buildOverlayDecoration(context),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.isTextColor
? EditorOverlayTitle(
text: AppFlowyEditorLocalizations.current.textColor,
)
: EditorOverlayTitle(
text: AppFlowyEditorLocalizations.current.highlightColor,
),
const SizedBox(height: 6),
// if it is in highlight color mode with a highlight color, show the clear highlight color button
widget.isTextColor == false && widget.selectedColorHex != null
? ClearHighlightColorButton(
editorState: widget.editorState,
dismissOverlay: widget.onDismiss,
)
: const SizedBox.shrink(),
// set text color back to null(default text color)
widget.isTextColor == true && widget.selectedColorHex != null
? ResetTextColorButton(
editorState: widget.editorState,
dismissOverlay: widget.onDismiss,
)
: const SizedBox.shrink(),
CustomColorItem(
colorController: _colorHexController,
opacityController: _colorOpacityController,
onSubmittedColorHex: widget.onSubmittedColorHex,
),
_buildColorItems(
widget.colorOptions,
widget.selectedColorHex,
),
],
),
),
),
);
}
Widget _buildColorItems(
List<ColorOption> options,
String? selectedColor,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: options
.map((e) => _buildColorItem(e, e.colorHex == selectedColor))
.toList(),
);
}
Widget _buildColorItem(ColorOption option, bool isChecked) {
return SizedBox(
height: 36,
child: TextButton.icon(
onPressed: () {
widget.onSubmittedColorHex(option.colorHex);
},
icon: SizedBox.square(
dimension: 12,
child: Container(
decoration: BoxDecoration(
color: option.colorHex.toColor(),
shape: BoxShape.circle,
),
),
),
style: buildOverlayButtonStyle(context),
label: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
option.name,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.fade,
style: TextStyle(
color: Theme.of(context).textTheme.labelLarge?.color,
),
),
),
// checkbox
if (isChecked) const FlowySvg(name: 'checkmark'),
],
),
),
);
}
String? _convertHexToOpacity(String? colorHex) {
if (colorHex == null) return null;
final opacityHex = colorHex.substring(2, 4);
final opacity = int.parse(opacityHex, radix: 16) / 2.55;
return opacity.toStringAsFixed(0);
}
String? _extractColorHex(String? colorHex) {
if (colorHex == null) return null;
return colorHex.substring(4);
}
}
class ResetTextColorButton extends StatelessWidget {
const ResetTextColorButton({
super.key,
required this.editorState,
required this.dismissOverlay,
});
final EditorState editorState;
final Function() dismissOverlay;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 32,
child: TextButton.icon(
onPressed: () {
final selection = editorState.selection!;
editorState
.formatDelta(selection, {BuiltInAttributeKey.textColor: null});
dismissOverlay();
},
icon: FlowySvg(
name: 'reset_text_color',
width: 13,
height: 13,
color: Theme.of(context).iconTheme.color,
),
label: Text(
AppFlowyEditorLocalizations.current.resetToDefaultColor,
style: TextStyle(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.left,
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return Theme.of(context).hoverColor;
}
return Colors.transparent;
},
),
alignment: Alignment.centerLeft,
),
),
);
}
}
class ClearHighlightColorButton extends StatelessWidget {
const ClearHighlightColorButton({
super.key,
required this.editorState,
required this.dismissOverlay,
});
final EditorState editorState;
final Function() dismissOverlay;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 32,
child: TextButton.icon(
onPressed: () {
final selection = editorState.selection!;
editorState.formatDelta(
selection,
{BuiltInAttributeKey.highlightColor: null},
);
dismissOverlay();
},
icon: FlowySvg(
name: 'clear_highlight_color',
width: 13,
height: 13,
color: Theme.of(context).iconTheme.color,
),
label: Text(
AppFlowyEditorLocalizations.current.clearHighlightColor,
style: TextStyle(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.left,
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return Theme.of(context).hoverColor;
}
return Colors.transparent;
},
),
alignment: Alignment.centerLeft,
),
),
);
}
}
class CustomColorItem extends StatefulWidget {
const CustomColorItem({
super.key,
required this.colorController,
required this.opacityController,
required this.onSubmittedColorHex,
});
final TextEditingController colorController;
final TextEditingController opacityController;
final void Function(String color) onSubmittedColorHex;
@override
State<CustomColorItem> createState() => _CustomColorItemState();
}
class _CustomColorItemState extends State<CustomColorItem> {
@override
Widget build(BuildContext context) {
return ExpansionTile(
tilePadding: const EdgeInsets.only(left: 8),
shape: Border.all(
color: Colors.transparent,
), // remove the default border when it is expanded
title: Row(
children: [
// color sample box
SizedBox.square(
dimension: 12,
child: Container(
decoration: BoxDecoration(
color: Color(
int.tryParse(
_combineColorHexAndOpacity(
widget.colorController.text,
widget.opacityController.text,
),
) ??
0xFFFFFFFF,
),
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppFlowyEditorLocalizations.current.customColor,
style: Theme.of(context).textTheme.labelLarge,
// same style as TextButton.icon
),
),
],
),
children: [
const SizedBox(height: 6),
_customColorDetailsTextField(
labelText: AppFlowyEditorLocalizations.current.hexValue,
controller: widget.colorController,
// update the color sample box when the text changes
onChanged: (_) => setState(() {}),
onSubmitted: _submitCustomColorHex,
),
const SizedBox(height: 10),
_customColorDetailsTextField(
labelText: AppFlowyEditorLocalizations.current.opacity,
controller: widget.opacityController,
// update the color sample box when the text changes
onChanged: (_) => setState(() {}),
onSubmitted: _submitCustomColorHex,
),
const SizedBox(height: 6),
],
);
}
Widget _customColorDetailsTextField({
required String labelText,
required TextEditingController controller,
Function(String)? onChanged,
Function(String)? onSubmitted,
}) {
return Padding(
padding: const EdgeInsets.only(right: 3),
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: labelText,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
),
style: Theme.of(context).textTheme.bodyMedium,
onChanged: onChanged,
onSubmitted: onSubmitted,
),
);
}
String _combineColorHexAndOpacity(String colorHex, String opacity) {
colorHex = _fixColorHex(colorHex);
opacity = _fixOpacity(opacity);
final opacityHex = (int.parse(opacity) * 2.55).round().toRadixString(16);
return '0x$opacityHex$colorHex';
}
String _fixColorHex(String colorHex) {
if (colorHex.length > 6) {
colorHex = colorHex.substring(0, 6);
}
if (int.tryParse(colorHex, radix: 16) == null) {
colorHex = 'FFFFFF';
}
return colorHex;
}
String _fixOpacity(String opacity) {
// if opacity is 0 - 99, return it
// otherwise return 100
RegExp regex = RegExp('^(0|[1-9][0-9]?)');
if (regex.hasMatch(opacity)) {
return opacity;
} else {
return '100';
}
}
void _submitCustomColorHex(String value) {
final String color = _combineColorHexAndOpacity(
widget.colorController.text,
widget.opacityController.text,
);
widget.onSubmittedColorHex(color);
}
}

View file

@ -0,0 +1,34 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final highlightColorItem = ToolbarItem(
id: 'editor.highlightColor',
group: 4,
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
String? highlightColorHex;
final selection = editorState.selection!;
final nodes = editorState.getNodesInSelection(selection);
final isHighlight = nodes.allSatisfyInSelection(selection, (delta) {
return delta.everyAttributes(
(attributes) => attributes[FlowyRichTextKeys.highlightColor] != null,
);
});
return IconItemWidget(
iconName: 'toolbar/highlight_color',
iconSize: const Size.square(14),
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.highlightColor,
onPressed: () {
showColorMenu(
context,
editorState,
selection,
currentColorHex: highlightColorHex,
isTextColor: false,
);
},
);
},
);

View file

@ -0,0 +1,33 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final textColorItem = ToolbarItem(
id: 'editor.textColor',
group: 4,
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
String? textColorHex;
final selection = editorState.selection!;
final nodes = editorState.getNodesInSelection(selection);
final isHighlight = nodes.allSatisfyInSelection(selection, (delta) {
return delta.everyAttributes(
(attributes) => attributes[FlowyRichTextKeys.textColor] != null,
);
});
return IconItemWidget(
iconName: 'toolbar/text_color',
isHighlight: isHighlight,
iconSize: const Size.square(14),
tooltip: AppFlowyEditorLocalizations.current.textColor,
onPressed: () {
showColorMenu(
context,
editorState,
selection,
currentColorHex: textColorHex,
isTextColor: true,
);
},
);
},
);

View file

@ -0,0 +1,69 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart';
final List<ToolbarItem> markdownFormatItems = [
_FormatToolbarItem(
id: 'editor.underline',
name: 'underline',
tooltip:
'${AppFlowyEditorLocalizations.current.underline}${shortcutTooltips('⌘ + U', 'CTRL + U', 'CTRL + U')}',
),
_FormatToolbarItem(
id: 'editor.bold',
name: 'bold',
tooltip:
'${AppFlowyEditorLocalizations.current.bold}${shortcutTooltips('⌘ + B', 'CTRL + B', 'CTRL + B')}',
),
_FormatToolbarItem(
id: 'editor.italic',
name: 'italic',
tooltip:
'${AppFlowyEditorLocalizations.current.bold}${shortcutTooltips('⌘ + I', 'CTRL + I', 'CTRL + I')}',
),
_FormatToolbarItem(
id: 'editor.strikethrough',
name: 'strikethrough',
tooltip:
'${AppFlowyEditorLocalizations.current.strikethrough}${shortcutTooltips('⌘ + SHIFT + S', 'CTRL + SHIFT + S', 'CTRL + SHIFT + S')}',
),
_FormatToolbarItem(
id: 'editor.code',
name: 'code',
tooltip:
'${AppFlowyEditorLocalizations.current.embedCode}${shortcutTooltips('⌘ + E', 'CTRL + E', 'CTRL + E')}',
),
];
class _FormatToolbarItem extends ToolbarItem {
_FormatToolbarItem({
required String id,
required String name,
required String tooltip,
}) : super(
id: 'editor.$id',
group: 2,
isActive: (editorState) {
final selection = editorState.selection;
if (selection == null) {
return false;
}
final nodes = editorState.getNodesInSelection(selection);
return nodes.every((element) => element.delta != null);
},
builder: (context, editorState) {
final selection = editorState.selection!;
final nodes = editorState.getNodesInSelection(selection);
final isHighlight = nodes.allSatisfyInSelection(selection, (delta) {
return delta.everyAttributes(
(attributes) => attributes[name] == true,
);
});
return IconItemWidget(
iconName: 'toolbar/$name',
isHighlight: isHighlight,
tooltip: tooltip,
onPressed: () => editorState.toggleAttribute(name),
);
},
);
}

View file

@ -0,0 +1,41 @@
import 'package:appflowy_editor/appflowy_editor.dart';
List<ToolbarItem> headingItems = [1, 2, 3]
.map((index) => _HeadingToolbarItem(index))
.toList(growable: false);
class _HeadingToolbarItem extends ToolbarItem {
final int level;
_HeadingToolbarItem(this.level)
: super(
id: 'editor.h$level',
group: 1,
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight =
node.type == 'heading' && node.attributes['level'] == level;
return IconItemWidget(
iconName: 'toolbar/h$level',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.heading1,
onPressed: () => editorState.formatNode(
selection,
(node) => node.copyWith(
type: isHighlight
? ParagraphBlockKeys.type
: HeadingBlockKeys.type,
attributes: {
HeadingBlockKeys.level: level,
HeadingBlockKeys.backgroundColor:
node.attributes[blockComponentBackgroundColor],
'delta': (node.delta ?? Delta()).toJson(),
},
),
),
);
},
);
}

View file

@ -0,0 +1,25 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart';
// unused now.
final highlightItem = ToolbarItem(
id: 'editor.highlight',
group: 5,
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final nodes = editorState.getNodesInSelection(selection);
final isHighlight = nodes.allSatisfyInSelection(selection, (delta) {
return delta.everyAttributes(
(attributes) => attributes['href'] != null,
);
});
return IconItemWidget(
iconName: 'toolbar/link',
isHighlight: isHighlight,
tooltip:
'${AppFlowyEditorLocalizations.current.link}${shortcutTooltips("⌘ + K", "CTRL + K", "CTRL + K")}',
onPressed: () {},
);
},
);

View file

@ -0,0 +1,58 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class IconItemWidget extends StatelessWidget {
const IconItemWidget({
super.key,
this.size = const Size.square(30.0),
this.iconSize = const Size.square(18.0),
required this.iconName,
required this.isHighlight,
this.tooltip,
this.onPressed,
});
final Size size;
final Size iconSize;
final String iconName;
final bool isHighlight;
final String? tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
Widget child = FlowySvg(
name: iconName,
color: isHighlight ? Colors.lightBlue : null,
width: iconSize.width,
height: iconSize.height,
);
if (onPressed != null) {
child = MouseRegion(
cursor: SystemMouseCursors.click,
child: IconButton(
hoverColor: Colors.transparent,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: EdgeInsets.zero,
icon: child,
iconSize: size.width,
onPressed: onPressed,
),
);
}
if (tooltip != null) {
child = Tooltip(
textAlign: TextAlign.center,
preferBelow: false,
message: tooltip,
child: child,
);
}
return SizedBox(
width: size.width,
height: size.height,
child: child,
);
}
}

View file

@ -0,0 +1,138 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/overlay_util.dart';
class LinkMenu extends StatefulWidget {
const LinkMenu({
Key? key,
this.linkText,
this.editorState,
required this.onSubmitted,
required this.onOpenLink,
required this.onCopyLink,
required this.onRemoveLink,
}) : super(key: key);
final String? linkText;
final EditorState? editorState;
final void Function(String text) onSubmitted;
final VoidCallback onOpenLink;
final VoidCallback onCopyLink;
final VoidCallback onRemoveLink;
@override
State<LinkMenu> createState() => _LinkMenuState();
}
class _LinkMenuState extends State<LinkMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_textEditingController.text = widget.linkText ?? '';
_focusNode.requestFocus();
}
@override
void dispose() {
_textEditingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
decoration: buildOverlayDecoration(context),
padding: const EdgeInsets.fromLTRB(10, 10, 10, 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EditorOverlayTitle(
text: AppFlowyEditorLocalizations.current.addYourLink,
),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 16.0),
if (widget.linkText != null) ...[
_buildIconButton(
iconName: 'link',
text: AppFlowyEditorLocalizations.current.openLink,
onPressed: widget.onOpenLink,
),
_buildIconButton(
iconName: 'copy',
text: AppFlowyEditorLocalizations.current.copyLink,
onPressed: widget.onCopyLink,
),
_buildIconButton(
iconName: 'delete',
text: AppFlowyEditorLocalizations.current.removeLink,
onPressed: widget.onRemoveLink,
),
]
],
),
);
}
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
padding: const EdgeInsets.all(4.0),
icon: const FlowySvg(
name: 'clear',
width: 24,
height: 24,
),
onPressed: _textEditingController.clear,
splashRadius: 5,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
),
),
);
}
Widget _buildIconButton({
required String iconName,
required String text,
required VoidCallback onPressed,
}) {
return SizedBox(
height: 36,
child: TextButton.icon(
icon: FlowySvg(
name: iconName,
color: Theme.of(context).textTheme.labelLarge?.color,
),
label: Row(
// This row is used to align the text to the left
children: [
Text(
text,
style: TextStyle(
color: Theme.of(context).textTheme.labelLarge?.color,
),
),
],
),
style: buildOverlayButtonStyle(context),
onPressed: onPressed,
),
);
}
}

View file

@ -0,0 +1,118 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/link/link_menu.dart';
import 'package:appflowy_editor/src/infra/clipboard.dart';
import 'package:flutter/material.dart';
final linkItem = ToolbarItem(
id: 'editor.link',
group: 4,
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final nodes = editorState.getNodesInSelection(selection);
final isHref = nodes.allSatisfyInSelection(selection, (delta) {
return delta.everyAttributes(
(attributes) => attributes[FlowyRichTextKeys.href] != null,
);
});
return IconItemWidget(
iconName: 'toolbar/link',
isHighlight: isHref,
tooltip: AppFlowyEditorLocalizations.current.link,
onPressed: () {
showLinkMenu(context, editorState, selection, isHref);
},
);
},
);
void showLinkMenu(
BuildContext context,
EditorState editorState,
Selection selection,
bool isHref,
) {
// Since link format is only available for single line selection,
// the first rect(also the only rect) is used as the starting reference point for the [overlay] position
final rect = editorState.selectionRects().first;
// should abstract this logic to a method
// ----
final left = rect.left + 10;
double? top;
double? bottom;
final offset = rect.center;
final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero);
final editorHeight = editorState.renderBox!.size.height;
final threshold = editorOffset.dy + editorHeight - 150;
if (offset.dy > threshold) {
bottom = editorOffset.dy + editorHeight - rect.top - 5;
} else {
top = rect.bottom + 5;
}
// ----
// get node, index and length for formatting text when the link is removed
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
// get link address if the selection is already a link
String? linkText;
if (isHref) {
linkText = editorState.getDeltaAttributeValueInSelection(
BuiltInAttributeKey.href,
selection,
);
}
OverlayEntry? overlay;
void dismissOverlay() {
overlay?.remove();
overlay = null;
editorState.service.keyboardService?.enable();
}
overlay = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: left,
builder: (context) {
return LinkMenu(
linkText: linkText,
editorState: editorState,
onOpenLink: () async {
await safeLaunchUrl(linkText);
},
onSubmitted: (text) async {
await editorState.formatDelta(selection, {
BuiltInAttributeKey.href: text,
});
dismissOverlay();
},
onCopyLink: () {
AppFlowyClipboard.setData(text: linkText);
dismissOverlay();
},
onRemoveLink: () {
final transaction = editorState.transaction
..formatText(
node,
index,
length,
{BuiltInAttributeKey.href: null},
);
editorState.apply(transaction);
dismissOverlay();
},
);
},
).build();
Overlay.of(context).insert(overlay!);
editorState.service.keyboardService?.disable();
}

Some files were not shown because too many files have changed in this diff Show more