Merge pull request #880 from LucasXu0/selection_menu_refactor
[Improvement] Refactor selection menu
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 512 B After Width: | Height: | Size: 512 B |
Before Width: | Height: | Size: 561 B After Width: | Height: | Size: 561 B |
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 354 B |
Before Width: | Height: | Size: 771 B After Width: | Height: | Size: 771 B |
Before Width: | Height: | Size: 892 B After Width: | Height: | Size: 892 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:appflowy_editor/src/infra/log.dart';
|
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:appflowy_editor/src/service/service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -54,6 +55,9 @@ class EditorState {
|
|||||||
/// with this variable.
|
/// with this variable.
|
||||||
LogConfiguration get logConfiguration => LogConfiguration();
|
LogConfiguration get logConfiguration => LogConfiguration();
|
||||||
|
|
||||||
|
/// Stores the selection menu items.
|
||||||
|
List<SelectionMenuItem> selectionMenuItems = [];
|
||||||
|
|
||||||
final UndoManager undoManager = UndoManager();
|
final UndoManager undoManager = UndoManager();
|
||||||
Selection? _cursorSelection;
|
Selection? _cursorSelection;
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ class TransactionBuilder {
|
|||||||
TransactionBuilder(this.state);
|
TransactionBuilder(this.state);
|
||||||
|
|
||||||
/// Commits the operations to the state
|
/// Commits the operations to the state
|
||||||
commit() {
|
Future<void> commit() async {
|
||||||
final transaction = finish();
|
final transaction = finish();
|
||||||
state.apply(transaction);
|
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:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -32,6 +33,7 @@ class AppFlowyEditor extends StatefulWidget {
|
|||||||
required this.editorState,
|
required this.editorState,
|
||||||
this.customBuilders = const {},
|
this.customBuilders = const {},
|
||||||
this.keyEventHandlers = const [],
|
this.keyEventHandlers = const [],
|
||||||
|
this.selectionMenuItems = const [],
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final EditorState editorState;
|
final EditorState editorState;
|
||||||
@ -42,6 +44,8 @@ class AppFlowyEditor extends StatefulWidget {
|
|||||||
/// Keyboard event handlers.
|
/// Keyboard event handlers.
|
||||||
final List<AppFlowyKeyEventHandler> keyEventHandlers;
|
final List<AppFlowyKeyEventHandler> keyEventHandlers;
|
||||||
|
|
||||||
|
final List<SelectionMenuItem> selectionMenuItems;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
|
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
|
||||||
}
|
}
|
||||||
@ -53,6 +57,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
editorState.selectionMenuItems = widget.selectionMenuItems;
|
||||||
editorState.service.renderPluginService = _createRenderPlugin();
|
editorState.service.renderPluginService = _createRenderPlugin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,35 +73,35 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppFlowyScroll(
|
return AppFlowyScroll(
|
||||||
key: editorState.service.scrollServiceKey,
|
key: editorState.service.scrollServiceKey,
|
||||||
child: AppFlowySelection(
|
child: AppFlowySelection(
|
||||||
key: editorState.service.selectionServiceKey,
|
key: editorState.service.selectionServiceKey,
|
||||||
|
editorState: editorState,
|
||||||
|
child: AppFlowyInput(
|
||||||
|
key: editorState.service.inputServiceKey,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
child: AppFlowyInput(
|
child: AppFlowyKeyboard(
|
||||||
key: editorState.service.inputServiceKey,
|
key: editorState.service.keyboardServiceKey,
|
||||||
|
handlers: [
|
||||||
|
...defaultKeyEventHandlers,
|
||||||
|
...widget.keyEventHandlers,
|
||||||
|
],
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
child: AppFlowyKeyboard(
|
child: FlowyToolbar(
|
||||||
key: editorState.service.keyboardServiceKey,
|
key: editorState.service.toolbarServiceKey,
|
||||||
handlers: [
|
|
||||||
...defaultKeyEventHandlers,
|
|
||||||
...widget.keyEventHandlers,
|
|
||||||
],
|
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
child: FlowyToolbar(
|
child: editorState.service.renderPluginService.buildPluginWidget(
|
||||||
key: editorState.service.toolbarServiceKey,
|
NodeWidgetContext(
|
||||||
editorState: editorState,
|
context: context,
|
||||||
child:
|
node: editorState.document.root,
|
||||||
editorState.service.renderPluginService.buildPluginWidget(
|
editorState: editorState,
|
||||||
NodeWidgetContext(
|
|
||||||
context: context,
|
|
||||||
node: editorState.document.root,
|
|
||||||
editorState: editorState,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
));
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AppFlowyRenderPlugin _createRenderPlugin() => AppFlowyRenderPlugin(
|
AppFlowyRenderPlugin _createRenderPlugin() => AppFlowyRenderPlugin(
|
||||||
|
@ -1,67 +1,12 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
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/operation/transaction_builder.dart';
|
||||||
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
|
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
|
||||||
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
|
||||||
import 'package:appflowy_editor/src/service/keyboard_service.dart';
|
import 'package:appflowy_editor/src/service/keyboard_service.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
|
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
@visibleForTesting
|
SelectionMenuService? _selectionMenuService;
|
||||||
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;
|
|
||||||
AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
|
AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
|
||||||
if (event.logicalKey != LogicalKeyboardKey.slash) {
|
if (event.logicalKey != LogicalKeyboardKey.slash) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
@ -89,360 +34,11 @@ AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
|
|||||||
selection.end.offset - selection.start.offset, event.character ?? '')
|
selection.end.offset - selection.start.offset, event.character ?? '')
|
||||||
..commit();
|
..commit();
|
||||||
|
|
||||||
_editorState = editorState;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_selectionChangeBySlash = false;
|
_selectionMenuService =
|
||||||
|
SelectionMenu(context: context, editorState: editorState);
|
||||||
editorState.service.selectionService.currentSelection
|
_selectionMenuService?.show();
|
||||||
.removeListener(clearPopupList);
|
|
||||||
editorState.service.selectionService.currentSelection
|
|
||||||
.addListener(clearPopupList);
|
|
||||||
|
|
||||||
editorState.service.scrollService?.disable();
|
|
||||||
|
|
||||||
showPopupList(context, editorState, selectionRects.first.bottomRight);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
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/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/infra/log.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -74,6 +73,13 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
enable();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
@ -95,6 +101,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
KeyEventResult onKey(RawKeyEvent event) {
|
KeyEventResult onKey(RawKeyEvent event) {
|
||||||
|
if (!isFocus) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
Log.keyboard.debug('on keyboard event $event');
|
Log.keyboard.debug('on keyboard event $event');
|
||||||
|
|
||||||
if (event is! RawKeyDownEvent) {
|
if (event is! RawKeyDownEvent) {
|
||||||
@ -122,10 +132,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
|||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
|
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
|
||||||
if (!isFocus) {
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
return onKey(event);
|
return onKey(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,11 +31,8 @@ flutter:
|
|||||||
# To add assets to your package, add an assets section, like this:
|
# To add assets to your package, add an assets section, like this:
|
||||||
assets:
|
assets:
|
||||||
- assets/images/toolbar/
|
- assets/images/toolbar/
|
||||||
- assets/images/popup_list/
|
- assets/images/selection_menu/
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/document.json
|
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
#
|
#
|
||||||
# For details regarding assets in packages, see
|
# For details regarding assets in packages, see
|
||||||
# https://flutter.dev/assets-and-images/#from-packages
|
# https://flutter.dev/assets-and-images/#from-packages
|
||||||
|
@ -102,14 +102,21 @@ class EditorWidgetTester {
|
|||||||
bool isAltPressed = false,
|
bool isAltPressed = false,
|
||||||
bool isMetaPressed = false,
|
bool isMetaPressed = false,
|
||||||
}) async {
|
}) async {
|
||||||
final testRawKeyEventData = TestRawKeyEventData(
|
if (!isControlPressed &&
|
||||||
logicalKey: key,
|
!isShiftPressed &&
|
||||||
isControlPressed: isControlPressed,
|
!isAltPressed &&
|
||||||
isShiftPressed: isShiftPressed,
|
!isMetaPressed) {
|
||||||
isAltPressed: isAltPressed,
|
await tester.sendKeyDownEvent(key);
|
||||||
isMetaPressed: isMetaPressed,
|
} else {
|
||||||
).toKeyEvent;
|
final testRawKeyEventData = TestRawKeyEventData(
|
||||||
_editorState.service.keyboardService!.onKey(testRawKeyEventData);
|
logicalKey: key,
|
||||||
|
isControlPressed: isControlPressed,
|
||||||
|
isShiftPressed: isShiftPressed,
|
||||||
|
isAltPressed: isAltPressed,
|
||||||
|
isMetaPressed: isMetaPressed,
|
||||||
|
).toKeyEvent;
|
||||||
|
_editorState.service.keyboardService!.onKey(testRawKeyEventData);
|
||||||
|
}
|
||||||
await tester.pumpAndSettle();
|
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/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/document/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/document/selection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
test('create state tree', () async {
|
test('create state tree', () async {
|
||||||
final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
final stateTree = StateTree.fromJson(data);
|
// final stateTree = StateTree.fromJson(data);
|
||||||
expect(stateTree.root.type, 'root');
|
// expect(stateTree.root.type, 'root');
|
||||||
expect(stateTree.root.toJson(), data['document']);
|
// expect(stateTree.root.toJson(), data['document']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search node by Path in state tree', () async {
|
test('search node by Path in state tree', () async {
|
||||||
final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
final stateTree = StateTree.fromJson(data);
|
// final stateTree = StateTree.fromJson(data);
|
||||||
final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
||||||
expect(checkBoxNode != null, true);
|
// expect(checkBoxNode != null, true);
|
||||||
final textType = checkBoxNode!.attributes['text-type'];
|
// final textType = checkBoxNode!.attributes['text-type'];
|
||||||
expect(textType != null, true);
|
// expect(textType != null, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search node by Self in state tree', () async {
|
test('search node by Self in state tree', () async {
|
||||||
final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
final stateTree = StateTree.fromJson(data);
|
// final stateTree = StateTree.fromJson(data);
|
||||||
final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
||||||
expect(checkBoxNode != null, true);
|
// expect(checkBoxNode != null, true);
|
||||||
final textType = checkBoxNode!.attributes['text-type'];
|
// final textType = checkBoxNode!.attributes['text-type'];
|
||||||
expect(textType != null, true);
|
// expect(textType != null, true);
|
||||||
final path = checkBoxNode.path;
|
// final path = checkBoxNode.path;
|
||||||
expect(pathEquals(path, [1, 0]), true);
|
// expect(pathEquals(path, [1, 0]), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('insert node in state tree', () async {
|
test('insert node in state tree', () async {
|
||||||
final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
final stateTree = StateTree.fromJson(data);
|
// final stateTree = StateTree.fromJson(data);
|
||||||
final insertNode = Node.fromJson({
|
// final insertNode = Node.fromJson({
|
||||||
'type': 'text',
|
// 'type': 'text',
|
||||||
});
|
// });
|
||||||
bool result = stateTree.insert([1, 1], [insertNode]);
|
// bool result = stateTree.insert([1, 1], [insertNode]);
|
||||||
expect(result, true);
|
// expect(result, true);
|
||||||
expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
|
// expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete node in state tree', () async {
|
test('delete node in state tree', () async {
|
||||||
final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
final stateTree = StateTree.fromJson(data);
|
// final stateTree = StateTree.fromJson(data);
|
||||||
stateTree.delete([1, 1], 1);
|
// stateTree.delete([1, 1], 1);
|
||||||
final node = stateTree.nodeAtPath([1, 1]);
|
// final node = stateTree.nodeAtPath([1, 1]);
|
||||||
expect(node != null, true);
|
// expect(node != null, true);
|
||||||
expect(node!.attributes['tag'], '**');
|
// expect(node!.attributes['tag'], '**');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update node in state tree', () async {
|
test('update node in state tree', () async {
|
||||||
final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
final stateTree = StateTree.fromJson(data);
|
// final stateTree = StateTree.fromJson(data);
|
||||||
final test = stateTree.update([1, 1], {'text-type': 'heading1'});
|
// final test = stateTree.update([1, 1], {'text-type': 'heading1'});
|
||||||
expect(test, true);
|
// expect(test, true);
|
||||||
final updatedNode = stateTree.nodeAtPath([1, 1]);
|
// final updatedNode = stateTree.nodeAtPath([1, 1]);
|
||||||
expect(updatedNode != null, true);
|
// expect(updatedNode != null, true);
|
||||||
expect(updatedNode!.attributes['text-type'], 'heading1');
|
// expect(updatedNode!.attributes['text-type'], 'heading1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test path utils 1', () {
|
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();
|
||||||
|
}
|
@ -0,0 +1,150 @@
|
|||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.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:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import '../../infra/test_editor.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
setUpAll(() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('selection_menu_widget.dart', () {
|
||||||
|
for (var i = 0; i < defaultSelectionMenuItems.length; i++) {
|
||||||
|
testWidgets('Selects number.$i item in selection menu', (tester) async {
|
||||||
|
final editor = await _prepare(tester);
|
||||||
|
for (var j = 0; j < i; j++) {
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.arrowDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.enter);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuWidget, skipOffstage: false),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
await _testDefaultSelectionMenuItems(i, editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Search item in selection menu util no results', (tester) async {
|
||||||
|
final editor = await _prepare(tester);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(2),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(3),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(2),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyX);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(1),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(1),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Search item in selection menu and presses esc', (tester) async {
|
||||||
|
final editor = await _prepare(tester);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(2),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.escape);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Search item in selection menu and presses backspace',
|
||||||
|
(tester) async {
|
||||||
|
final editor = await _prepare(tester);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNWidgets(2),
|
||||||
|
);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<EditorWidgetTester> _prepare(WidgetTester tester) async {
|
||||||
|
const text = 'Welcome to Appflowy 😁';
|
||||||
|
const lines = 3;
|
||||||
|
final editor = tester.editor;
|
||||||
|
for (var i = 0; i < lines; i++) {
|
||||||
|
editor.insertTextNode(text);
|
||||||
|
}
|
||||||
|
await editor.startTesting();
|
||||||
|
await editor.updateSelection(Selection.single(path: [1], startOffset: 0));
|
||||||
|
await editor.pressLogicKey(LogicalKeyboardKey.slash);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byType(SelectionMenuWidget, skipOffstage: false),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final item in defaultSelectionMenuItems) {
|
||||||
|
expect(find.byWidget(item.icon), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Future.value(editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testDefaultSelectionMenuItems(
|
||||||
|
int index, EditorWidgetTester editor) async {
|
||||||
|
expect(editor.documentLength, 4);
|
||||||
|
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
|
||||||
|
final node = editor.nodeAtPath([2]);
|
||||||
|
final item = defaultSelectionMenuItems[index];
|
||||||
|
if (item.name == 'Text') {
|
||||||
|
expect(node?.subtype == null, true);
|
||||||
|
} else if (item.name == 'Heading 1') {
|
||||||
|
expect(node?.subtype, StyleKey.heading);
|
||||||
|
expect(node?.attributes.heading, StyleKey.h1);
|
||||||
|
} else if (item.name == 'Heading 2') {
|
||||||
|
expect(node?.subtype, StyleKey.heading);
|
||||||
|
expect(node?.attributes.heading, StyleKey.h2);
|
||||||
|
} else if (item.name == 'Heading 3') {
|
||||||
|
expect(node?.subtype, StyleKey.heading);
|
||||||
|
expect(node?.attributes.heading, StyleKey.h3);
|
||||||
|
} else if (item.name == 'Bulleted list') {
|
||||||
|
expect(node?.subtype, StyleKey.bulletedList);
|
||||||
|
} else if (item.name == 'Checkbox') {
|
||||||
|
expect(node?.subtype, StyleKey.checkbox);
|
||||||
|
expect(node?.attributes.check, false);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.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:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import '../../infra/test_editor.dart';
|
import '../../infra/test_editor.dart';
|
||||||
@ -10,7 +12,7 @@ void main() async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('slash_handler.dart', () {
|
group('slash_handler.dart', () {
|
||||||
testWidgets('Presses / to trigger popup list ', (tester) async {
|
testWidgets('Presses / to trigger selection menu', (tester) async {
|
||||||
const text = 'Welcome to Appflowy 😁';
|
const text = 'Welcome to Appflowy 😁';
|
||||||
const lines = 3;
|
const lines = 3;
|
||||||
final editor = tester.editor;
|
final editor = tester.editor;
|
||||||
@ -23,9 +25,12 @@ void main() async {
|
|||||||
|
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
|
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
|
||||||
|
|
||||||
expect(find.byType(PopupListWidget, skipOffstage: false), findsOneWidget);
|
expect(
|
||||||
|
find.byType(SelectionMenuWidget, skipOffstage: false),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
for (final item in popupListItems) {
|
for (final item in defaultSelectionMenuItems) {
|
||||||
expect(find.byWidget(item.icon), findsOneWidget);
|
expect(find.byWidget(item.icon), findsOneWidget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +38,10 @@ void main() async {
|
|||||||
|
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
expect(find.byType(PopupListWidget, skipOffstage: false), findsNothing);
|
expect(
|
||||||
|
find.byType(SelectionMenuItemWidget, skipOffstage: false),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|