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:
parent
c0dbd025e0
commit
6a4a8ae818
61 changed files with 1574 additions and 125 deletions
3
assets/mobile/toolbar_icons/heading.svg
Normal file
3
assets/mobile/toolbar_icons/heading.svg
Normal 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 |
3
assets/mobile/toolbar_icons/list.svg
Normal file
3
assets/mobile/toolbar_icons/list.svg
Normal 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 |
|
|
@ -26,6 +26,6 @@ subprojects {
|
|||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
lib/src/editor/toolbar/desktop/desktop.dart
Normal file
14
lib/src/editor/toolbar/desktop/desktop.dart
Normal 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';
|
||||
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
4
lib/src/editor/toolbar/desktop/items/color/color.dart
Normal file
4
lib/src/editor/toolbar/desktop/items/color/color.dart
Normal 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';
|
||||
154
lib/src/editor/toolbar/desktop/items/color/color_menu.dart
Normal file
154
lib/src/editor/toolbar/desktop/items/color/color_menu.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
408
lib/src/editor/toolbar/desktop/items/color/color_picker.dart
Normal file
408
lib/src/editor/toolbar/desktop/items/color/color_picker.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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: () {},
|
||||
);
|
||||
},
|
||||
);
|
||||
58
lib/src/editor/toolbar/desktop/items/icon_item_widget.dart
Normal file
58
lib/src/editor/toolbar/desktop/items/icon_item_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/src/editor/toolbar/desktop/items/link/link_menu.dart
Normal file
138
lib/src/editor/toolbar/desktop/items/link/link_menu.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/src/editor/toolbar/desktop/items/link/link_toolbar_item.dart
Normal file
118
lib/src/editor/toolbar/desktop/items/link/link_toolbar_item.dart
Normal 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
Loading…
Reference in a new issue