feat: [Improvement] Refactor selection menu #879
|
|
@ -1,58 +0,0 @@
|
|||
{
|
||||
"document": {
|
||||
"type": "root",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"delta": [],
|
||||
"attributes": {
|
||||
"subtype": "with-heading"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"delta": [],
|
||||
"attributes": {
|
||||
"tag": "*"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"delta": [],
|
||||
"attributes": {
|
||||
"text-type": "heading2",
|
||||
"check": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"delta": [],
|
||||
"attributes": {
|
||||
"text-type": "checkbox",
|
||||
"check": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"delta": [],
|
||||
"attributes": {
|
||||
"tag": "**"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attributes": {
|
||||
"url": "x.png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"attributes": {
|
||||
"url": "x.mp4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 4L12.5 4" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 8H12.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 12H12.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="4" cy="4" r="0.5" fill="#333333"/>
|
||||
<circle cx="4" cy="8" r="0.5" fill="#333333"/>
|
||||
<circle cx="4" cy="12" r="0.5" fill="#333333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 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="M6.5 8L8.11538 9.5L13.5 4.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 8.5V11.8889C13 12.1836 12.8829 12.4662 12.6746 12.6746C12.4662 12.8829 12.1836 13 11.8889 13H4.11111C3.81643 13 3.53381 12.8829 3.32544 12.6746C3.11706 12.4662 3 12.1836 3 11.8889V4.11111C3 3.81643 3.11706 3.53381 3.32544 3.32544C3.53381 3.11706 3.81643 3 4.11111 3H10.2222" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 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="M8.25344 3.59998H9.63344V12H8.25344V8.36398H4.65344V12H3.27344V3.59998H4.65344V7.04398H8.25344V3.59998Z" fill="#333333"/>
|
||||
<path d="M12.0325 6.39998H12.9925V12H11.8885V7.56798L10.8325 7.86398L10.5605 6.91998L12.0325 6.39998Z" fill="#333333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 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.67531 3.59998H9.05531V12H7.67531V8.36398H4.07531V12H2.69531V3.59998H4.07531V7.04398H7.67531V3.59998Z" fill="#333333"/>
|
||||
<path d="M10.1104 12V11.176L12.0224 9.20798C12.449 8.75998 12.6624 8.38664 12.6624 8.08798C12.6624 7.86931 12.593 7.69331 12.4544 7.55998C12.321 7.42664 12.1477 7.35998 11.9344 7.35998C11.513 7.35998 11.201 7.57864 10.9984 8.01598L10.0704 7.47198C10.2464 7.08798 10.4997 6.79464 10.8304 6.59198C11.161 6.38931 11.5237 6.28798 11.9184 6.28798C12.425 6.28798 12.8597 6.44798 13.2224 6.76798C13.585 7.08264 13.7664 7.50931 13.7664 8.04798C13.7664 8.62931 13.4597 9.22664 12.8464 9.83998L11.7504 10.936H13.8544V12H10.1104Z" fill="#333333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 771 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.62063 3.59998H9.00063V12H7.62063V8.36398H4.02062V12H2.64062V3.59998H4.02062V7.04398H7.62063V3.59998Z" fill="#333333"/>
|
||||
<path d="M12.6637 8.67198C13.0424 8.78398 13.349 8.98131 13.5837 9.26398C13.8237 9.54131 13.9437 9.87731 13.9437 10.272C13.9437 10.848 13.749 11.2986 13.3597 11.624C12.9757 11.9493 12.5037 12.112 11.9437 12.112C11.5064 12.112 11.1144 12.0133 10.7677 11.816C10.4264 11.6133 10.1784 11.3173 10.0237 10.928L10.9677 10.384C11.1064 10.816 11.4317 11.032 11.9437 11.032C12.2264 11.032 12.445 10.9653 12.5997 10.832C12.7597 10.6933 12.8397 10.5066 12.8397 10.272C12.8397 10.0426 12.7597 9.85864 12.5997 9.71998C12.445 9.58131 12.2264 9.51198 11.9437 9.51198H11.7037L11.2797 8.87198L12.3837 7.43198H10.1917V6.39998H13.7117V7.31198L12.6637 8.67198Z" fill="#333333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 892 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.201 6.4H3.001V12H2.081V7.384L0.953 7.704L0.729 6.92L2.201 6.4ZM3.91156 12V11.1L6.35156 8.61C6.9449 8.01667 7.24156 7.50333 7.24156 7.07C7.24156 6.73 7.13823 6.46667 6.93156 6.28C6.73156 6.08667 6.4749 5.99 6.16156 5.99C5.5749 5.99 5.14156 6.28 4.86156 6.86L3.89156 6.29C4.11156 5.82333 4.42156 5.47 4.82156 5.23C5.22156 4.99 5.6649 4.87 6.15156 4.87C6.7649 4.87 7.29156 5.06333 7.73156 5.45C8.17156 5.83667 8.39156 6.36333 8.39156 7.03C8.39156 7.74333 7.9949 8.50333 7.20156 9.31L5.62156 10.89H8.52156V12H3.91156ZM12.9025 7.032C13.5105 7.176 14.0025 7.46 14.3785 7.884C14.7625 8.3 14.9545 8.824 14.9545 9.456C14.9545 10.296 14.6705 10.956 14.1025 11.436C13.5345 11.916 12.8385 12.156 12.0145 12.156C11.3745 12.156 10.7985 12.008 10.2865 11.712C9.78253 11.416 9.41853 10.984 9.19453 10.416L10.3705 9.732C10.6185 10.452 11.1665 10.812 12.0145 10.812C12.4945 10.812 12.8745 10.692 13.1545 10.452C13.4345 10.204 13.5745 9.872 13.5745 9.456C13.5745 9.04 13.4345 8.712 13.1545 8.472C12.8745 8.232 12.4945 8.112 12.0145 8.112H11.7025L11.1505 7.284L12.9625 4.896H9.44653V3.6H14.6065V4.776L12.9025 7.032Z" fill="#333333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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.15625 11.8359L6.43768 9.85414H2.46662L1.74805 11.8359H0.5L3.7903 3H5.11399L8.4043 11.8359H7.15625ZM2.87003 8.75596H6.03427L4.44584 4.40112L2.87003 8.75596Z" fill="#333333"/>
|
||||
<path d="M14.4032 5.52454H15.5V11.8359H14.4032V10.7504C13.8569 11.5835 13.0627 12 12.0206 12C11.1381 12 10.386 11.6802 9.76403 11.0407C9.14211 10.3927 8.83114 9.60589 8.83114 8.68022C8.83114 7.75456 9.14211 6.97195 9.76403 6.3324C10.386 5.68443 11.1381 5.36045 12.0206 5.36045C13.0627 5.36045 13.8569 5.777 14.4032 6.6101V5.52454ZM12.1593 10.9397C12.798 10.9397 13.3317 10.7251 13.7603 10.2959C14.1889 9.85835 14.4032 9.31978 14.4032 8.68022C14.4032 8.04067 14.1889 7.50631 13.7603 7.07714C13.3317 6.63955 12.798 6.42076 12.1593 6.42076C11.5289 6.42076 10.9995 6.63955 10.5708 7.07714C10.1422 7.50631 9.92791 8.04067 9.92791 8.68022C9.92791 9.31978 10.1422 9.85835 10.5708 10.2959C10.9995 10.7251 11.5289 10.9397 12.1593 10.9397Z" fill="#333333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'package:appflowy_editor/src/infra/log.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||
import 'package:appflowy_editor/src/service/service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -54,6 +55,9 @@ class EditorState {
|
|||
/// with this variable.
|
||||
LogConfiguration get logConfiguration => LogConfiguration();
|
||||
|
||||
/// Stores the selection menu items.
|
||||
List<SelectionMenuItem> selectionMenuItems = [];
|
||||
|
||||
final UndoManager undoManager = UndoManager();
|
||||
Selection? _cursorSelection;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class TransactionBuilder {
|
|||
TransactionBuilder(this.state);
|
||||
|
||||
/// Commits the operations to the state
|
||||
commit() {
|
||||
Future<void> commit() async {
|
||||
final transaction = finish();
|
||||
state.apply(transaction);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SelectionMenuItemWidget extends StatelessWidget {
|
||||
const SelectionMenuItemWidget({
|
||||
Key? key,
|
||||
required this.editorState,
|
||||
required this.menuService,
|
||||
required this.item,
|
||||
required this.isSelected,
|
||||
this.width = 140.0,
|
||||
this.selectedColor = const Color(0xFFE0F8FF),
|
||||
}) : super(key: key);
|
||||
|
||||
final EditorState editorState;
|
||||
final SelectionMenuService menuService;
|
||||
final SelectionMenuItem item;
|
||||
final double width;
|
||||
final bool isSelected;
|
||||
final Color selectedColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: TextButton.icon(
|
||||
icon: item.icon,
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
overlayColor: MaterialStateProperty.all(selectedColor),
|
||||
backgroundColor: isSelected
|
||||
? MaterialStateProperty.all(selectedColor)
|
||||
: MaterialStateProperty.all(Colors.transparent),
|
||||
),
|
||||
label: Text(
|
||||
item.name,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
item.handler(editorState, menuService);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class SelectionMenuService {
|
||||
Offset get topLeft;
|
||||
|
||||
void show();
|
||||
void dismiss();
|
||||
}
|
||||
|
||||
class SelectionMenu implements SelectionMenuService {
|
||||
SelectionMenu({
|
||||
required this.context,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final BuildContext context;
|
||||
final EditorState editorState;
|
||||
|
||||
OverlayEntry? _selectionMenuEntry;
|
||||
bool _selectionUpdateByInner = false;
|
||||
|
||||
@override
|
||||
void dismiss() {
|
||||
if (_selectionMenuEntry != null) {
|
||||
editorState.service.keyboardService?.enable();
|
||||
editorState.service.scrollService?.enable();
|
||||
}
|
||||
|
||||
_selectionMenuEntry?.remove();
|
||||
_selectionMenuEntry = null;
|
||||
|
||||
// workaround: SelectionService has been released after hot reload.
|
||||
final isSelectionDisposed =
|
||||
editorState.service.selectionServiceKey.currentState == null;
|
||||
if (!isSelectionDisposed) {
|
||||
final selectionService = editorState.service.selectionService;
|
||||
selectionService.currentSelection.removeListener(_onSelectionChange);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void show() {
|
||||
dismiss();
|
||||
|
||||
final selectionService = editorState.service.selectionService;
|
||||
final selectionRects = selectionService.selectionRects;
|
||||
if (selectionRects.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final offset = selectionRects.first.bottomRight + const Offset(10, 10);
|
||||
|
||||
_selectionMenuEntry = OverlayEntry(builder: (context) {
|
||||
return Positioned(
|
||||
top: offset.dy,
|
||||
left: offset.dx,
|
||||
child: SelectionMenuWidget(
|
||||
items: [
|
||||
..._defaultSelectionMenuItems,
|
||||
...editorState.selectionMenuItems,
|
||||
],
|
||||
maxItemInRow: 5,
|
||||
editorState: editorState,
|
||||
menuService: this,
|
||||
onExit: () {
|
||||
dismiss();
|
||||
},
|
||||
onSelectionUpdate: () {
|
||||
_selectionUpdateByInner = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Overlay.of(context)?.insert(_selectionMenuEntry!);
|
||||
|
||||
editorState.service.keyboardService?.disable();
|
||||
editorState.service.scrollService?.disable();
|
||||
selectionService.currentSelection.addListener(_onSelectionChange);
|
||||
}
|
||||
|
||||
@override
|
||||
// TODO: implement topLeft
|
||||
Offset get topLeft => throw UnimplementedError();
|
||||
|
||||
void _onSelectionChange() {
|
||||
// workaround: SelectionService has been released after hot reload.
|
||||
final isSelectionDisposed =
|
||||
editorState.service.selectionServiceKey.currentState == null;
|
||||
if (!isSelectionDisposed) {
|
||||
final selectionService = editorState.service.selectionService;
|
||||
if (selectionService.currentSelection.value == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_selectionUpdateByInner) {
|
||||
_selectionUpdateByInner = false;
|
||||
return;
|
||||
}
|
||||
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<SelectionMenuItem> get defaultSelectionMenuItems =>
|
||||
_defaultSelectionMenuItems;
|
||||
final List<SelectionMenuItem> _defaultSelectionMenuItems = [
|
||||
SelectionMenuItem(
|
||||
name: 'Text',
|
||||
icon: _selectionMenuIcon('text'),
|
||||
keywords: ['text'],
|
||||
handler: (editorState, menuService) {
|
||||
insertTextNodeAfterSelection(editorState, {});
|
||||
},
|
||||
),
|
||||
SelectionMenuItem(
|
||||
name: 'Heading 1',
|
||||
icon: _selectionMenuIcon('h1'),
|
||||
keywords: ['heading 1, h1'],
|
||||
handler: (editorState, menuService) {
|
||||
insertHeadingAfterSelection(editorState, StyleKey.h1);
|
||||
},
|
||||
),
|
||||
SelectionMenuItem(
|
||||
name: 'Heading 2',
|
||||
icon: _selectionMenuIcon('h2'),
|
||||
keywords: ['heading 2, h2'],
|
||||
handler: (editorState, menuService) {
|
||||
insertHeadingAfterSelection(editorState, StyleKey.h2);
|
||||
},
|
||||
),
|
||||
SelectionMenuItem(
|
||||
name: 'Heading 3',
|
||||
icon: _selectionMenuIcon('h3'),
|
||||
keywords: ['heading 3, h3'],
|
||||
handler: (editorState, menuService) {
|
||||
insertHeadingAfterSelection(editorState, StyleKey.h3);
|
||||
},
|
||||
),
|
||||
SelectionMenuItem(
|
||||
name: 'Bulleted list',
|
||||
icon: _selectionMenuIcon('bulleted_list'),
|
||||
keywords: ['bulleted list', 'list', 'unordered list'],
|
||||
handler: (editorState, menuService) {
|
||||
insertBulletedListAfterSelection(editorState);
|
||||
},
|
||||
),
|
||||
SelectionMenuItem(
|
||||
name: 'Checkbox',
|
||||
icon: _selectionMenuIcon('checkbox'),
|
||||
keywords: ['todo list', 'list', 'checkbox list'],
|
||||
handler: (editorState, menuService) {
|
||||
insertCheckboxAfterSelection(editorState);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
Widget _selectionMenuIcon(String name) {
|
||||
return FlowySvg(
|
||||
name: 'selection_menu/$name',
|
||||
color: Colors.black,
|
||||
width: 18.0,
|
||||
height: 18.0,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Selection Menu Item
|
||||
class SelectionMenuItem {
|
||||
SelectionMenuItem({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.keywords,
|
||||
required this.handler,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final Widget icon;
|
||||
|
||||
/// Customizes keywords for item.
|
||||
///
|
||||
/// The keywords are used to quickly retrieve items.
|
||||
final List<String> keywords;
|
||||
final void Function(EditorState editorState, SelectionMenuService menuService)
|
||||
handler;
|
||||
}
|
||||
|
||||
class SelectionMenuWidget extends StatefulWidget {
|
||||
const SelectionMenuWidget({
|
||||
Key? key,
|
||||
required this.items,
|
||||
required this.maxItemInRow,
|
||||
required this.editorState,
|
||||
required this.menuService,
|
||||
required this.onExit,
|
||||
required this.onSelectionUpdate,
|
||||
}) : super(key: key);
|
||||
|
||||
final List<SelectionMenuItem> items;
|
||||
final int maxItemInRow;
|
||||
|
||||
final SelectionMenuService menuService;
|
||||
final EditorState editorState;
|
||||
|
||||
final VoidCallback onSelectionUpdate;
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
State<SelectionMenuWidget> createState() => _SelectionMenuWidgetState();
|
||||
}
|
||||
|
||||
class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
|
||||
final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
|
||||
|
||||
int _selectedIndex = 0;
|
||||
List<SelectionMenuItem> _showingItems = [];
|
||||
|
||||
String _keyword = '';
|
||||
String get keyword => _keyword;
|
||||
set keyword(String newKeyword) {
|
||||
_keyword = newKeyword;
|
||||
|
||||
// Search items according to the keyword, and calculate the length of
|
||||
// the longest keyword, which is used to dismiss the selection_service.
|
||||
var maxKeywordLength = 0;
|
||||
final items = widget.items
|
||||
.where(
|
||||
(item) => item.keywords.any((keyword) {
|
||||
final value = keyword.contains(newKeyword);
|
||||
if (value) {
|
||||
maxKeywordLength = max(maxKeywordLength, keyword.length);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
Log.ui.debug('$items');
|
||||
|
||||
if (keyword.length >= maxKeywordLength + 2) {
|
||||
widget.onExit();
|
||||
} else {
|
||||
setState(() {
|
||||
_showingItems = items;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_showingItems = widget.items;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
onKey: _onKey,
|
||||
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: _showingItems.isEmpty
|
||||
? _buildNoResultsWidget(context)
|
||||
: _buildResultsWidget(
|
||||
context,
|
||||
_showingItems,
|
||||
_selectedIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsWidget(
|
||||
BuildContext buildContext,
|
||||
List<SelectionMenuItem> items,
|
||||
int selectedIndex,
|
||||
) {
|
||||
List<Widget> columns = [];
|
||||
List<Widget> itemWidgets = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (i != 0 && i % (widget.maxItemInRow) == 0) {
|
||||
columns.add(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: itemWidgets,
|
||||
));
|
||||
itemWidgets = [];
|
||||
}
|
||||
itemWidgets.add(SelectionMenuItemWidget(
|
||||
item: items[i],
|
||||
isSelected: selectedIndex == i,
|
||||
editorState: widget.editorState,
|
||||
menuService: widget.menuService,
|
||||
));
|
||||
}
|
||||
if (itemWidgets.isNotEmpty) {
|
||||
columns.add(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: itemWidgets,
|
||||
));
|
||||
itemWidgets = [];
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: columns,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoResultsWidget(BuildContext context) {
|
||||
return const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Material(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
'No results',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles arrow keys to switch selected items
|
||||
/// Handles keyword searches
|
||||
/// Handles enter to select item and esc to exit
|
||||
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
|
||||
Log.keyboard.debug('slash command, on key $event');
|
||||
if (event is! RawKeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final arrowKeys = [
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowDown
|
||||
];
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
|
||||
_deleteLastCharacters(length: keyword.length + 1);
|
||||
_showingItems[_selectedIndex]
|
||||
.handler(widget.editorState, widget.menuService);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onExit();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
if (keyword.isEmpty) {
|
||||
widget.onExit();
|
||||
} else {
|
||||
keyword = keyword.substring(0, keyword.length - 1);
|
||||
}
|
||||
_deleteLastCharacters();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.character != null &&
|
||||
!arrowKeys.contains(event.logicalKey)) {
|
||||
keyword += event.character!;
|
||||
_insertText(event.character!);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
var newSelectedIndex = _selectedIndex;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
newSelectedIndex -= widget.maxItemInRow;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
newSelectedIndex += widget.maxItemInRow;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
newSelectedIndex -= 1;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
newSelectedIndex += 1;
|
||||
}
|
||||
if (newSelectedIndex != _selectedIndex) {
|
||||
setState(() {
|
||||
_selectedIndex = newSelectedIndex.clamp(0, _showingItems.length - 1);
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _deleteLastCharacters({int length = 1}) {
|
||||
final selectionService = widget.editorState.service.selectionService;
|
||||
final selection = selectionService.currentSelection.value;
|
||||
final nodes = selectionService.currentSelectedNodes;
|
||||
if (selection != null && nodes.length == 1) {
|
||||
widget.onSelectionUpdate();
|
||||
TransactionBuilder(widget.editorState)
|
||||
..deleteText(
|
||||
nodes.first as TextNode,
|
||||
selection.start.offset - length,
|
||||
length,
|
||||
)
|
||||
..commit();
|
||||
}
|
||||
}
|
||||
|
||||
void _insertText(String text) {
|
||||
final selection =
|
||||
widget.editorState.service.selectionService.currentSelection.value;
|
||||
final nodes =
|
||||
widget.editorState.service.selectionService.currentSelectedNodes;
|
||||
if (selection != null && nodes.length == 1) {
|
||||
widget.onSelectionUpdate();
|
||||
TransactionBuilder(widget.editorState)
|
||||
..insertText(
|
||||
nodes.first as TextNode,
|
||||
selection.end.offset,
|
||||
text,
|
||||
)
|
||||
..commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ class AppFlowyEditor extends StatefulWidget {
|
|||
required this.editorState,
|
||||
this.customBuilders = const {},
|
||||
this.keyEventHandlers = const [],
|
||||
this.selectionMenuItems = const [],
|
||||
}) : super(key: key);
|
||||
|
||||
final EditorState editorState;
|
||||
|
|
@ -42,6 +44,8 @@ class AppFlowyEditor extends StatefulWidget {
|
|||
/// Keyboard event handlers.
|
||||
final List<AppFlowyKeyEventHandler> keyEventHandlers;
|
||||
|
||||
final List<SelectionMenuItem> selectionMenuItems;
|
||||
|
||||
@override
|
||||
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
|
||||
}
|
||||
|
|
@ -53,6 +57,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
editorState.selectionMenuItems = widget.selectionMenuItems;
|
||||
editorState.service.renderPluginService = _createRenderPlugin();
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +90,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||
child: FlowyToolbar(
|
||||
key: editorState.service.toolbarServiceKey,
|
||||
editorState: editorState,
|
||||
child:
|
||||
editorState.service.renderPluginService.buildPluginWidget(
|
||||
child: editorState.service.renderPluginService.buildPluginWidget(
|
||||
NodeWidgetContext(
|
||||
context: context,
|
||||
node: editorState.document.root,
|
||||
|
|
@ -96,7 +100,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppFlowyRenderPlugin _createRenderPlugin() => AppFlowyRenderPlugin(
|
||||
|
|
|
|||
|
|
@ -1,67 +1,12 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||
import 'package:appflowy_editor/src/infra/log.dart';
|
||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
||||
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
|
||||
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
|
||||
import 'package:appflowy_editor/src/service/keyboard_service.dart';
|
||||
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@visibleForTesting
|
||||
List<PopupListItem> get popupListItems => _popupListItems;
|
||||
|
||||
final List<PopupListItem> _popupListItems = [
|
||||
PopupListItem(
|
||||
text: 'Text',
|
||||
keywords: ['text'],
|
||||
icon: _popupListIcon('text'),
|
||||
handler: (editorState) {
|
||||
insertTextNodeAfterSelection(editorState, {});
|
||||
},
|
||||
),
|
||||
PopupListItem(
|
||||
text: 'Heading 1',
|
||||
keywords: ['h1', 'heading 1'],
|
||||
icon: _popupListIcon('h1'),
|
||||
handler: (editorState) =>
|
||||
insertHeadingAfterSelection(editorState, StyleKey.h1),
|
||||
),
|
||||
PopupListItem(
|
||||
text: 'Heading 2',
|
||||
keywords: ['h2', 'heading 2'],
|
||||
icon: _popupListIcon('h2'),
|
||||
handler: (editorState) =>
|
||||
insertHeadingAfterSelection(editorState, StyleKey.h2),
|
||||
),
|
||||
PopupListItem(
|
||||
text: 'Heading 3',
|
||||
keywords: ['h3', 'heading 3'],
|
||||
icon: _popupListIcon('h3'),
|
||||
handler: (editorState) =>
|
||||
insertHeadingAfterSelection(editorState, StyleKey.h3),
|
||||
),
|
||||
PopupListItem(
|
||||
text: 'Bulleted List',
|
||||
keywords: ['bulleted list'],
|
||||
icon: _popupListIcon('bullets'),
|
||||
handler: (editorState) => insertBulletedListAfterSelection(editorState),
|
||||
),
|
||||
PopupListItem(
|
||||
text: 'To-do List',
|
||||
keywords: ['checkbox', 'todo'],
|
||||
icon: _popupListIcon('checkbox'),
|
||||
handler: (editorState) => insertCheckboxAfterSelection(editorState),
|
||||
),
|
||||
];
|
||||
|
||||
OverlayEntry? _popupListOverlay;
|
||||
EditorState? _editorState;
|
||||
bool _selectionChangeBySlash = false;
|
||||
SelectionMenuService? _selectionMenuService;
|
||||
AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
|
||||
if (event.logicalKey != LogicalKeyboardKey.slash) {
|
||||
return KeyEventResult.ignored;
|
||||
|
|
@ -89,360 +34,11 @@ AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
|
|||
selection.end.offset - selection.start.offset, event.character ?? '')
|
||||
..commit();
|
||||
|
||||
_editorState = editorState;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_selectionChangeBySlash = false;
|
||||
|
||||
editorState.service.selectionService.currentSelection
|
||||
.removeListener(clearPopupList);
|
||||
editorState.service.selectionService.currentSelection
|
||||
.addListener(clearPopupList);
|
||||
|
||||
editorState.service.scrollService?.disable();
|
||||
|
||||
showPopupList(context, editorState, selectionRects.first.bottomRight);
|
||||
_selectionMenuService =
|
||||
SelectionMenu(context: context, editorState: editorState);
|
||||
_selectionMenuService?.show();
|
||||
});
|
||||
|
||||
return KeyEventResult.handled;
|
||||
};
|
||||
|
||||
void showPopupList(
|
||||
BuildContext context, EditorState editorState, Offset offset) {
|
||||
_popupListOverlay?.remove();
|
||||
_popupListOverlay = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
top: offset.dy,
|
||||
left: offset.dx,
|
||||
child: PopupListWidget(
|
||||
editorState: editorState,
|
||||
items: _popupListItems,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context)?.insert(_popupListOverlay!);
|
||||
}
|
||||
|
||||
void clearPopupList() {
|
||||
if (_popupListOverlay == null || _editorState == null) {
|
||||
return;
|
||||
}
|
||||
final isSelectionDisposed =
|
||||
_editorState?.service.selectionServiceKey.currentState != null;
|
||||
if (isSelectionDisposed) {
|
||||
final selection =
|
||||
_editorState?.service.selectionService.currentSelection.value;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_selectionChangeBySlash) {
|
||||
_selectionChangeBySlash = false;
|
||||
return;
|
||||
}
|
||||
_popupListOverlay?.remove();
|
||||
_popupListOverlay = null;
|
||||
|
||||
_editorState?.service.keyboardService?.enable();
|
||||
_editorState?.service.scrollService?.enable();
|
||||
_editorState = null;
|
||||
}
|
||||
|
||||
class PopupListWidget extends StatefulWidget {
|
||||
const PopupListWidget({
|
||||
Key? key,
|
||||
required this.editorState,
|
||||
required this.items,
|
||||
this.maxItemInRow = 5,
|
||||
}) : super(key: key);
|
||||
|
||||
final EditorState editorState;
|
||||
final List<PopupListItem> items;
|
||||
final int maxItemInRow;
|
||||
|
||||
@override
|
||||
State<PopupListWidget> createState() => _PopupListWidgetState();
|
||||
}
|
||||
|
||||
class _PopupListWidgetState extends State<PopupListWidget> {
|
||||
final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
|
||||
int _selectedIndex = 0;
|
||||
List<PopupListItem> _items = [];
|
||||
|
||||
int _maxKeywordLength = 0;
|
||||
|
||||
String __keyword = '';
|
||||
String get _keyword => __keyword;
|
||||
set _keyword(String keyword) {
|
||||
__keyword = keyword;
|
||||
|
||||
final items = widget.items
|
||||
.where((item) =>
|
||||
item.keywords.any((keyword) => keyword.contains(_keyword)))
|
||||
.toList(growable: false);
|
||||
if (items.isNotEmpty) {
|
||||
var maxKeywordLength = 0;
|
||||
for (var item in _items) {
|
||||
for (var keyword in item.keywords) {
|
||||
maxKeywordLength = max(maxKeywordLength, keyword.length);
|
||||
}
|
||||
}
|
||||
_maxKeywordLength = maxKeywordLength;
|
||||
}
|
||||
|
||||
if (keyword.length >= _maxKeywordLength + 2) {
|
||||
clearPopupList();
|
||||
} else {
|
||||
setState(() {
|
||||
_selectedIndex = 0;
|
||||
_items = items;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_items = widget.items;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
onKey: _onKey,
|
||||
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: _items.isEmpty
|
||||
? _buildNoResultsWidget(context)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildColumns(_items, _selectedIndex),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoResultsWidget(BuildContext context) {
|
||||
return const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Material(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
'No results',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildColumns(List<PopupListItem> items, int selectedIndex) {
|
||||
List<Widget> columns = [];
|
||||
List<Widget> itemWidgets = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (i != 0 && i % (widget.maxItemInRow) == 0) {
|
||||
columns.add(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: itemWidgets,
|
||||
));
|
||||
itemWidgets = [];
|
||||
}
|
||||
itemWidgets.add(_PopupListItemWidget(
|
||||
editorState: widget.editorState,
|
||||
item: items[i],
|
||||
highlight: selectedIndex == i,
|
||||
));
|
||||
}
|
||||
if (itemWidgets.isNotEmpty) {
|
||||
columns.add(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: itemWidgets,
|
||||
));
|
||||
itemWidgets = [];
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
|
||||
Log.keyboard.debug('slash command, on key $event');
|
||||
if (event is! RawKeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final arrowKeys = [
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowDown
|
||||
];
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (0 <= _selectedIndex && _selectedIndex < _items.length) {
|
||||
_deleteLastCharacters(length: _keyword.length + 1);
|
||||
_items[_selectedIndex].handler(widget.editorState);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
clearPopupList();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
if (_keyword.isEmpty) {
|
||||
clearPopupList();
|
||||
} else {
|
||||
_keyword = _keyword.substring(0, _keyword.length - 1);
|
||||
}
|
||||
_deleteLastCharacters();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.character != null &&
|
||||
!arrowKeys.contains(event.logicalKey)) {
|
||||
_keyword += event.character!;
|
||||
_insertText(event.character!);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
var newSelectedIndex = _selectedIndex;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
newSelectedIndex -= widget.maxItemInRow;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
newSelectedIndex += widget.maxItemInRow;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
newSelectedIndex -= 1;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
newSelectedIndex += 1;
|
||||
}
|
||||
if (newSelectedIndex != _selectedIndex) {
|
||||
setState(() {
|
||||
_selectedIndex = max(0, min(_items.length - 1, newSelectedIndex));
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _deleteLastCharacters({int length = 1}) {
|
||||
final selection =
|
||||
widget.editorState.service.selectionService.currentSelection.value;
|
||||
final nodes =
|
||||
widget.editorState.service.selectionService.currentSelectedNodes;
|
||||
if (selection != null && nodes.length == 1) {
|
||||
_selectionChangeBySlash = true;
|
||||
TransactionBuilder(widget.editorState)
|
||||
..deleteText(
|
||||
nodes.first as TextNode,
|
||||
selection.start.offset - length,
|
||||
length,
|
||||
)
|
||||
..commit();
|
||||
}
|
||||
}
|
||||
|
||||
void _insertText(String text) {
|
||||
final selection =
|
||||
widget.editorState.service.selectionService.currentSelection.value;
|
||||
final nodes =
|
||||
widget.editorState.service.selectionService.currentSelectedNodes;
|
||||
if (selection != null && nodes.length == 1) {
|
||||
_selectionChangeBySlash = true;
|
||||
TransactionBuilder(widget.editorState)
|
||||
..insertText(
|
||||
nodes.first as TextNode,
|
||||
selection.end.offset,
|
||||
text,
|
||||
)
|
||||
..commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PopupListItemWidget extends StatelessWidget {
|
||||
const _PopupListItemWidget({
|
||||
Key? key,
|
||||
required this.highlight,
|
||||
required this.item,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final EditorState editorState;
|
||||
final PopupListItem item;
|
||||
final bool highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0),
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: TextButton.icon(
|
||||
icon: item.icon,
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
overlayColor: MaterialStateProperty.all(
|
||||
const Color(0xFFE0F8FF),
|
||||
),
|
||||
backgroundColor: highlight
|
||||
? MaterialStateProperty.all(const Color(0xFFE0F8FF))
|
||||
: MaterialStateProperty.all(Colors.transparent),
|
||||
),
|
||||
label: Text(
|
||||
item.text,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
item.handler(editorState);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PopupListItem {
|
||||
PopupListItem({
|
||||
required this.text,
|
||||
required this.keywords,
|
||||
this.message = '',
|
||||
required this.icon,
|
||||
required this.handler,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final List<String> keywords;
|
||||
final String message;
|
||||
final Widget icon;
|
||||
final void Function(EditorState editorState) handler;
|
||||
}
|
||||
|
||||
Widget _popupListIcon(String name) => FlowySvg(
|
||||
name: 'popup_list/$name',
|
||||
color: Colors.black,
|
||||
width: 18.0,
|
||||
height: 18.0,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/infra/log.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -74,6 +73,13 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
|||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
enable();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
|
|
@ -95,6 +101,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
|||
|
||||
@override
|
||||
KeyEventResult onKey(RawKeyEvent event) {
|
||||
if (!isFocus) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Log.keyboard.debug('on keyboard event $event');
|
||||
|
||||
if (event is! RawKeyDownEvent) {
|
||||
|
|
@ -122,10 +132,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
|||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
|
||||
if (!isFocus) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
return onKey(event);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,8 @@ flutter:
|
|||
# To add assets to your package, add an assets section, like this:
|
||||
assets:
|
||||
- assets/images/toolbar/
|
||||
- assets/images/popup_list/
|
||||
- assets/images/selection_menu/
|
||||
- assets/images/
|
||||
- assets/document.json
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ class EditorWidgetTester {
|
|||
bool isAltPressed = false,
|
||||
bool isMetaPressed = false,
|
||||
}) async {
|
||||
if (!isControlPressed &&
|
||||
!isShiftPressed &&
|
||||
!isAltPressed &&
|
||||
!isMetaPressed) {
|
||||
await tester.sendKeyDownEvent(key);
|
||||
} else {
|
||||
final testRawKeyEventData = TestRawKeyEventData(
|
||||
logicalKey: key,
|
||||
isControlPressed: isControlPressed,
|
||||
|
|
@ -110,6 +116,7 @@ class EditorWidgetTester {
|
|||
isMetaPressed: isMetaPressed,
|
||||
).toKeyEvent;
|
||||
_editorState.service.keyboardService!.onKey(testRawKeyEventData);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +1,73 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/document/state_tree.dart';
|
||||
import 'package:appflowy_editor/src/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/position.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('create state tree', () async {
|
||||
final String response = await rootBundle.loadString('assets/document.json');
|
||||
final data = Map<String, Object>.from(json.decode(response));
|
||||
final stateTree = StateTree.fromJson(data);
|
||||
expect(stateTree.root.type, 'root');
|
||||
expect(stateTree.root.toJson(), data['document']);
|
||||
// final String response = await rootBundle.loadString('assets/document.json');
|
||||
// final data = Map<String, Object>.from(json.decode(response));
|
||||
// final stateTree = StateTree.fromJson(data);
|
||||
// expect(stateTree.root.type, 'root');
|
||||
// expect(stateTree.root.toJson(), data['document']);
|
||||
});
|
||||
|
||||
test('search node by Path in state tree', () async {
|
||||
final String response = await rootBundle.loadString('assets/document.json');
|
||||
final data = Map<String, Object>.from(json.decode(response));
|
||||
final stateTree = StateTree.fromJson(data);
|
||||
final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
||||
expect(checkBoxNode != null, true);
|
||||
final textType = checkBoxNode!.attributes['text-type'];
|
||||
expect(textType != null, true);
|
||||
// final String response = await rootBundle.loadString('assets/document.json');
|
||||
// final data = Map<String, Object>.from(json.decode(response));
|
||||
// final stateTree = StateTree.fromJson(data);
|
||||
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
||||
// expect(checkBoxNode != null, true);
|
||||
// final textType = checkBoxNode!.attributes['text-type'];
|
||||
// expect(textType != null, true);
|
||||
});
|
||||
|
||||
test('search node by Self in state tree', () async {
|
||||
final String response = await rootBundle.loadString('assets/document.json');
|
||||
final data = Map<String, Object>.from(json.decode(response));
|
||||
final stateTree = StateTree.fromJson(data);
|
||||
final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
||||
expect(checkBoxNode != null, true);
|
||||
final textType = checkBoxNode!.attributes['text-type'];
|
||||
expect(textType != null, true);
|
||||
final path = checkBoxNode.path;
|
||||
expect(pathEquals(path, [1, 0]), true);
|
||||
// final String response = await rootBundle.loadString('assets/document.json');
|
||||
// final data = Map<String, Object>.from(json.decode(response));
|
||||
// final stateTree = StateTree.fromJson(data);
|
||||
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
||||
// expect(checkBoxNode != null, true);
|
||||
// final textType = checkBoxNode!.attributes['text-type'];
|
||||
// expect(textType != null, true);
|
||||
// final path = checkBoxNode.path;
|
||||
// expect(pathEquals(path, [1, 0]), true);
|
||||
});
|
||||
|
||||
test('insert node in state tree', () async {
|
||||
final String response = await rootBundle.loadString('assets/document.json');
|
||||
final data = Map<String, Object>.from(json.decode(response));
|
||||
final stateTree = StateTree.fromJson(data);
|
||||
final insertNode = Node.fromJson({
|
||||
'type': 'text',
|
||||
});
|
||||
bool result = stateTree.insert([1, 1], [insertNode]);
|
||||
expect(result, true);
|
||||
expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
|
||||
// final String response = await rootBundle.loadString('assets/document.json');
|
||||
// final data = Map<String, Object>.from(json.decode(response));
|
||||
// final stateTree = StateTree.fromJson(data);
|
||||
// final insertNode = Node.fromJson({
|
||||
// 'type': 'text',
|
||||
// });
|
||||
// bool result = stateTree.insert([1, 1], [insertNode]);
|
||||
// expect(result, true);
|
||||
// expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
|
||||
});
|
||||
|
||||
test('delete node in state tree', () async {
|
||||
final String response = await rootBundle.loadString('assets/document.json');
|
||||
final data = Map<String, Object>.from(json.decode(response));
|
||||
final stateTree = StateTree.fromJson(data);
|
||||
stateTree.delete([1, 1], 1);
|
||||
final node = stateTree.nodeAtPath([1, 1]);
|
||||
expect(node != null, true);
|
||||
expect(node!.attributes['tag'], '**');
|
||||
// final String response = await rootBundle.loadString('assets/document.json');
|
||||
// final data = Map<String, Object>.from(json.decode(response));
|
||||
// final stateTree = StateTree.fromJson(data);
|
||||
// stateTree.delete([1, 1], 1);
|
||||
// final node = stateTree.nodeAtPath([1, 1]);
|
||||
// expect(node != null, true);
|
||||
// expect(node!.attributes['tag'], '**');
|
||||
});
|
||||
|
||||
test('update node in state tree', () async {
|
||||
final String response = await rootBundle.loadString('assets/document.json');
|
||||
final data = Map<String, Object>.from(json.decode(response));
|
||||
final stateTree = StateTree.fromJson(data);
|
||||
final test = stateTree.update([1, 1], {'text-type': 'heading1'});
|
||||
expect(test, true);
|
||||
final updatedNode = stateTree.nodeAtPath([1, 1]);
|
||||
expect(updatedNode != null, true);
|
||||
expect(updatedNode!.attributes['text-type'], 'heading1');
|
||||
// final String response = await rootBundle.loadString('assets/document.json');
|
||||
// final data = Map<String, Object>.from(json.decode(response));
|
||||
// final stateTree = StateTree.fromJson(data);
|
||||
// final test = stateTree.update([1, 1], {'text-type': 'heading1'});
|
||||
// expect(test, true);
|
||||
// final updatedNode = stateTree.nodeAtPath([1, 1]);
|
||||
// expect(updatedNode != null, true);
|
||||
// expect(updatedNode!.attributes['text-type'], 'heading1');
|
||||
});
|
||||
|
||||
test('test path utils 1', () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../../infra/test_editor.dart';
|
||||
|
||||
void main() async {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
group('selection_menu_item_widget.dart', () {
|
||||
testWidgets('test selection menu item widget', (tester) async {
|
||||
bool flag = false;
|
||||
final editorState = tester.editor.editorState;
|
||||
final menuService = _TestSelectionMenuService();
|
||||
const icon = Icon(Icons.abc);
|
||||
final item = SelectionMenuItem(
|
||||
name: 'example',
|
||||
icon: icon,
|
||||
keywords: ['example A', 'example B'],
|
||||
handler: (editorState, menuService) {
|
||||
flag = true;
|
||||
},
|
||||
);
|
||||
final widget = SelectionMenuItemWidget(
|
||||
editorState: editorState,
|
||||
menuService: menuService,
|
||||
item: item,
|
||||
isSelected: true,
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: widget));
|
||||
await tester.tap(find.byType(SelectionMenuItemWidget));
|
||||
expect(flag, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _TestSelectionMenuService implements SelectionMenuService {
|
||||
@override
|
||||
void dismiss() {}
|
||||
|
||||
@override
|
||||
void show() {}
|
||||
|
||||
@override
|
||||
Offset get topLeft => throw UnimplementedError();
|
||||
}
|
||||