mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-01 18:43:22 +00:00
fix: turn into issues (#6576)
* fix: cover title issues * fix: the selection should be cleared if selecting child node * fix: exclude the blocks that are not supported in the 'turn into' types * fix: add logs * fix: floating toolbar ai status * test: selecting the parent should deselect all the child nodes as well * chore: 'Copy Link' to 'Copy link' * fix: select all and turn into block doesn't work on Windows * test: calculate turn into selection test * fix: option button tests
This commit is contained in:
parent
ec408940e4
commit
7b031b228e
@ -2,6 +2,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@ -143,5 +144,37 @@ void main() {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'selecting the parent should deselect all the child nodes as well',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
const name = 'Test Document';
|
||||
await tester.createNewPageWithNameUnderParent(name: name);
|
||||
await tester.openPage(name);
|
||||
|
||||
// create a nested list
|
||||
// Item 1
|
||||
// Nested Item 1
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText('Item 1');
|
||||
await tester.ime.insertCharacter('\n');
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.ime.insertText('Nested Item 1');
|
||||
|
||||
// select the 'Nested Item 1' and then tap the option button of the 'Item 1'
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final selection = Selection.collapsed(
|
||||
Position(path: [0, 0], offset: 1),
|
||||
);
|
||||
editorState.selection = selection;
|
||||
await tester.pumpAndSettle();
|
||||
expect(editorState.selection, selection);
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
expect(editorState.selection, Selection.collapsed(Position(path: [0])));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import 'document_create_and_delete_test.dart'
|
||||
import 'document_inline_page_reference_test.dart'
|
||||
as document_inline_page_reference_test;
|
||||
import 'document_more_actions_test.dart' as document_more_actions_test;
|
||||
import 'document_option_action_test.dart' as document_option_action_test;
|
||||
import 'document_shortcuts_test.dart' as document_shortcuts_test;
|
||||
import 'document_text_direction_test.dart' as document_text_direction_test;
|
||||
import 'document_with_cover_image_test.dart' as document_with_cover_image_test;
|
||||
@ -40,7 +39,6 @@ void main() {
|
||||
document_codeblock_paste_test.main();
|
||||
document_alignment_test.main();
|
||||
document_text_direction_test.main();
|
||||
document_option_action_test.main();
|
||||
document_with_image_block_test.main();
|
||||
document_with_multi_image_block_test.main();
|
||||
document_inline_page_reference_test.main();
|
||||
|
||||
@ -252,6 +252,10 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
);
|
||||
insertedNode.add(afterNode);
|
||||
insertedNode.addAll(node.children.map((e) => e.copyWith()));
|
||||
} else if (!EditorOptionActionType.turnInto.supportTypes
|
||||
.contains(node.type)) {
|
||||
afterNode = node.copyWith();
|
||||
insertedNode.add(afterNode);
|
||||
} else {
|
||||
insertedNode.add(afterNode);
|
||||
}
|
||||
@ -267,4 +271,35 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Selection? calculateTurnIntoSelection(
|
||||
Node selectedNode,
|
||||
Selection? beforeSelection,
|
||||
) {
|
||||
final path = selectedNode.path;
|
||||
final selection = Selection.collapsed(
|
||||
Position(path: path),
|
||||
);
|
||||
|
||||
// if the previous selection is null or the start path is not in the same level as the current block path,
|
||||
// then update the selection with the current block path
|
||||
// for example,'|' means the selection,
|
||||
// case 1: collapsed selection
|
||||
// - bulleted item 1
|
||||
// - bulleted |item 2
|
||||
// when clicking the bulleted item 1, the bulleted item 1 path should be selected
|
||||
// case 2: not collapsed selection
|
||||
// - bulleted item 1
|
||||
// - bulleted |item 2
|
||||
// - bulleted |item 3
|
||||
// when clicking the bulleted item 1, the bulleted item 1 path should be selected
|
||||
if (beforeSelection == null ||
|
||||
beforeSelection.start.path.length != path.length ||
|
||||
!path.inSelection(beforeSelection)) {
|
||||
return selection;
|
||||
}
|
||||
// if the beforeSelection start with the current block,
|
||||
// then updating the selection with the beforeSelection that may contains multiple blocks
|
||||
return beforeSelection;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,12 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'draggable_option_button_feedback.dart';
|
||||
import 'option_button.dart';
|
||||
|
||||
// this flag is used to disable the tooltip of the block when it is dragged
|
||||
@visibleForTesting
|
||||
@ -66,13 +57,13 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
|
||||
onDragStarted: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
feedback: _OptionButtonFeedback(
|
||||
feedback: DraggleOptionButtonFeedback(
|
||||
controller: widget.controller,
|
||||
editorState: widget.editorState,
|
||||
blockComponentContext: widget.blockComponentContext,
|
||||
blockComponentBuilder: widget.blockComponentBuilder,
|
||||
),
|
||||
child: _OptionButton(
|
||||
child: OptionButton(
|
||||
isDragging: isDraggingAppFlowyEditorBlock,
|
||||
controller: widget.controller,
|
||||
editorState: widget.editorState,
|
||||
@ -130,251 +121,3 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionButtonFeedback extends StatefulWidget {
|
||||
const _OptionButtonFeedback({
|
||||
required this.controller,
|
||||
required this.editorState,
|
||||
required this.blockComponentContext,
|
||||
required this.blockComponentBuilder,
|
||||
});
|
||||
|
||||
final PopoverController controller;
|
||||
final EditorState editorState;
|
||||
final BlockComponentContext blockComponentContext;
|
||||
final Map<String, BlockComponentBuilder> blockComponentBuilder;
|
||||
|
||||
@override
|
||||
State<_OptionButtonFeedback> createState() => _OptionButtonFeedbackState();
|
||||
}
|
||||
|
||||
class _OptionButtonFeedbackState extends State<_OptionButtonFeedback> {
|
||||
late Node node;
|
||||
late BlockComponentContext blockComponentContext;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_setupLockComponentContext();
|
||||
widget.blockComponentContext.node.addListener(_updateBlockComponentContext);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.blockComponentContext.node
|
||||
.removeListener(_updateBlockComponentContext);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxWidth = (widget.editorState.renderBox?.size.width ??
|
||||
MediaQuery.of(context).size.width) *
|
||||
0.8;
|
||||
|
||||
return Opacity(
|
||||
opacity: 0.7,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Provider.value(
|
||||
value: widget.editorState,
|
||||
child: _buildBlock(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlock() {
|
||||
final node = widget.blockComponentContext.node;
|
||||
final builder = widget.blockComponentBuilder[node.type];
|
||||
if (builder == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
const unsupportedRenderBlockTypes = [
|
||||
TableBlockKeys.type,
|
||||
CustomImageBlockKeys.type,
|
||||
MultiImageBlockKeys.type,
|
||||
FileBlockKeys.type,
|
||||
DatabaseBlockKeys.boardType,
|
||||
DatabaseBlockKeys.calendarType,
|
||||
DatabaseBlockKeys.gridType,
|
||||
];
|
||||
|
||||
if (unsupportedRenderBlockTypes.contains(node.type)) {
|
||||
// unable to render table block without provider/context
|
||||
// render a placeholder instead
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: FlowyText(node.type.replaceAll('_', ' ').capitalize()),
|
||||
);
|
||||
}
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: widget.editorState),
|
||||
Provider.value(value: getIt<ReminderBloc>()),
|
||||
],
|
||||
child: builder.build(blockComponentContext),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBlockComponentContext() {
|
||||
setState(() => _setupLockComponentContext());
|
||||
}
|
||||
|
||||
void _setupLockComponentContext() {
|
||||
node = widget.blockComponentContext.node.copyWith();
|
||||
blockComponentContext = BlockComponentContext(
|
||||
widget.blockComponentContext.buildContext,
|
||||
node,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionButton extends StatefulWidget {
|
||||
const _OptionButton({
|
||||
required this.controller,
|
||||
required this.editorState,
|
||||
required this.blockComponentContext,
|
||||
required this.isDragging,
|
||||
});
|
||||
|
||||
final PopoverController controller;
|
||||
final EditorState editorState;
|
||||
final BlockComponentContext blockComponentContext;
|
||||
final ValueNotifier<bool> isDragging;
|
||||
|
||||
@override
|
||||
State<_OptionButton> createState() => _OptionButtonState();
|
||||
}
|
||||
|
||||
const _interceptorKey = 'document_option_button_interceptor';
|
||||
|
||||
class _OptionButtonState extends State<_OptionButton> {
|
||||
late final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: _interceptorKey,
|
||||
canTap: (details) => !_isTapInBounds(details.globalPosition),
|
||||
);
|
||||
|
||||
// the selection will be cleared when tap the option button
|
||||
// so we need to restore the selection after tap the option button
|
||||
Selection? beforeSelection;
|
||||
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.editorState.service.selectionService.registerGestureInterceptor(
|
||||
gestureInterceptor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
_interceptorKey,
|
||||
);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.isDragging,
|
||||
builder: (context, isDragging, child) {
|
||||
return BlockActionButton(
|
||||
svg: FlowySvgs.drag_element_s,
|
||||
showTooltip: !isDragging,
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_drag.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_toMove.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_click.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPointerDown: () {
|
||||
if (widget.editorState.selection != null) {
|
||||
beforeSelection = widget.editorState.selection;
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (widget.editorState.selection != null) {
|
||||
beforeSelection = widget.editorState.selection;
|
||||
}
|
||||
|
||||
widget.controller.show();
|
||||
|
||||
// update selection
|
||||
_updateBlockSelection();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBlockSelection() {
|
||||
if (beforeSelection == null) {
|
||||
final path = widget.blockComponentContext.node.path;
|
||||
final selection = Selection.collapsed(
|
||||
Position(path: path),
|
||||
);
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
customSelectionType: SelectionType.block,
|
||||
);
|
||||
} else {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
beforeSelection!,
|
||||
customSelectionType: SelectionType.block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTapInBounds(Offset offset) {
|
||||
if (renderBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final localPosition = renderBox!.globalToLocal(offset);
|
||||
final result = renderBox!.paintBounds.contains(localPosition);
|
||||
if (result) {
|
||||
beforeSelection = widget.editorState.selection;
|
||||
} else {
|
||||
beforeSelection = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,266 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class DraggleOptionButtonFeedback extends StatefulWidget {
|
||||
const DraggleOptionButtonFeedback({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.editorState,
|
||||
required this.blockComponentContext,
|
||||
required this.blockComponentBuilder,
|
||||
});
|
||||
|
||||
final PopoverController controller;
|
||||
final EditorState editorState;
|
||||
final BlockComponentContext blockComponentContext;
|
||||
final Map<String, BlockComponentBuilder> blockComponentBuilder;
|
||||
|
||||
@override
|
||||
State<DraggleOptionButtonFeedback> createState() =>
|
||||
_DraggleOptionButtonFeedbackState();
|
||||
}
|
||||
|
||||
class _DraggleOptionButtonFeedbackState
|
||||
extends State<DraggleOptionButtonFeedback> {
|
||||
late Node node;
|
||||
late BlockComponentContext blockComponentContext;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_setupLockComponentContext();
|
||||
widget.blockComponentContext.node.addListener(_updateBlockComponentContext);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.blockComponentContext.node
|
||||
.removeListener(_updateBlockComponentContext);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxWidth = (widget.editorState.renderBox?.size.width ??
|
||||
MediaQuery.of(context).size.width) *
|
||||
0.8;
|
||||
|
||||
return Opacity(
|
||||
opacity: 0.7,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Provider.value(
|
||||
value: widget.editorState,
|
||||
child: _buildBlock(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlock() {
|
||||
final node = widget.blockComponentContext.node;
|
||||
final builder = widget.blockComponentBuilder[node.type];
|
||||
if (builder == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
const unsupportedRenderBlockTypes = [
|
||||
TableBlockKeys.type,
|
||||
CustomImageBlockKeys.type,
|
||||
MultiImageBlockKeys.type,
|
||||
FileBlockKeys.type,
|
||||
DatabaseBlockKeys.boardType,
|
||||
DatabaseBlockKeys.calendarType,
|
||||
DatabaseBlockKeys.gridType,
|
||||
];
|
||||
|
||||
if (unsupportedRenderBlockTypes.contains(node.type)) {
|
||||
// unable to render table block without provider/context
|
||||
// render a placeholder instead
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: FlowyText(node.type.replaceAll('_', ' ').capitalize()),
|
||||
);
|
||||
}
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: widget.editorState),
|
||||
Provider.value(value: getIt<ReminderBloc>()),
|
||||
],
|
||||
child: builder.build(blockComponentContext),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBlockComponentContext() {
|
||||
setState(() => _setupLockComponentContext());
|
||||
}
|
||||
|
||||
void _setupLockComponentContext() {
|
||||
node = widget.blockComponentContext.node.copyWith();
|
||||
blockComponentContext = BlockComponentContext(
|
||||
widget.blockComponentContext.buildContext,
|
||||
node,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionButton extends StatefulWidget {
|
||||
const _OptionButton({
|
||||
required this.controller,
|
||||
required this.editorState,
|
||||
required this.blockComponentContext,
|
||||
required this.isDragging,
|
||||
});
|
||||
|
||||
final PopoverController controller;
|
||||
final EditorState editorState;
|
||||
final BlockComponentContext blockComponentContext;
|
||||
final ValueNotifier<bool> isDragging;
|
||||
|
||||
@override
|
||||
State<_OptionButton> createState() => _OptionButtonState();
|
||||
}
|
||||
|
||||
const _interceptorKey = 'document_option_button_interceptor';
|
||||
|
||||
class _OptionButtonState extends State<_OptionButton> {
|
||||
late final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: _interceptorKey,
|
||||
canTap: (details) => !_isTapInBounds(details.globalPosition),
|
||||
);
|
||||
|
||||
// the selection will be cleared when tap the option button
|
||||
// so we need to restore the selection after tap the option button
|
||||
Selection? beforeSelection;
|
||||
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.editorState.service.selectionService.registerGestureInterceptor(
|
||||
gestureInterceptor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
_interceptorKey,
|
||||
);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.isDragging,
|
||||
builder: (context, isDragging, child) {
|
||||
return BlockActionButton(
|
||||
svg: FlowySvgs.drag_element_s,
|
||||
showTooltip: !isDragging,
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_drag.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_toMove.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_click.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPointerDown: () {
|
||||
if (widget.editorState.selection != null) {
|
||||
beforeSelection = widget.editorState.selection;
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (widget.editorState.selection != null) {
|
||||
beforeSelection = widget.editorState.selection;
|
||||
}
|
||||
|
||||
widget.controller.show();
|
||||
|
||||
// update selection
|
||||
_updateBlockSelection();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBlockSelection() {
|
||||
if (beforeSelection == null) {
|
||||
final path = widget.blockComponentContext.node.path;
|
||||
final selection = Selection.collapsed(
|
||||
Position(path: path),
|
||||
);
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
customSelectionType: SelectionType.block,
|
||||
);
|
||||
} else {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
beforeSelection!,
|
||||
customSelectionType: SelectionType.block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTapInBounds(Offset offset) {
|
||||
if (renderBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final localPosition = renderBox!.globalToLocal(offset);
|
||||
final result = renderBox!.paintBounds.contains(localPosition);
|
||||
if (result) {
|
||||
beforeSelection = widget.editorState.selection;
|
||||
} else {
|
||||
beforeSelection = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
const _interceptorKey = 'document_option_button_interceptor';
|
||||
|
||||
class OptionButton extends StatefulWidget {
|
||||
const OptionButton({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.editorState,
|
||||
required this.blockComponentContext,
|
||||
required this.isDragging,
|
||||
});
|
||||
|
||||
final PopoverController controller;
|
||||
final EditorState editorState;
|
||||
final BlockComponentContext blockComponentContext;
|
||||
final ValueNotifier<bool> isDragging;
|
||||
|
||||
@override
|
||||
State<OptionButton> createState() => _OptionButtonState();
|
||||
}
|
||||
|
||||
class _OptionButtonState extends State<OptionButton> {
|
||||
late final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: _interceptorKey,
|
||||
canTap: (details) => !_isTapInBounds(details.globalPosition),
|
||||
);
|
||||
|
||||
// the selection will be cleared when tap the option button
|
||||
// so we need to restore the selection after tap the option button
|
||||
Selection? beforeSelection;
|
||||
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.editorState.service.selectionService.registerGestureInterceptor(
|
||||
gestureInterceptor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
_interceptorKey,
|
||||
);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.isDragging,
|
||||
builder: (context, isDragging, child) {
|
||||
return BlockActionButton(
|
||||
svg: FlowySvgs.drag_element_s,
|
||||
showTooltip: !isDragging,
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_drag.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_toMove.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_click.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final selection = widget.editorState.selection;
|
||||
if (selection != null) {
|
||||
beforeSelection = selection.normalized;
|
||||
}
|
||||
|
||||
widget.controller.show();
|
||||
|
||||
// update selection
|
||||
_updateBlockSelection(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBlockSelection(BuildContext context) {
|
||||
final cubit = context.read<BlockActionOptionCubit>();
|
||||
final selection = cubit.calculateTurnIntoSelection(
|
||||
widget.blockComponentContext.node,
|
||||
beforeSelection,
|
||||
);
|
||||
Log.info(
|
||||
'update block selection, beforeSelection: $beforeSelection, afterSelection: $selection',
|
||||
);
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
customSelectionType: SelectionType.block,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isTapInBounds(Offset offset) {
|
||||
final renderBox = this.renderBox;
|
||||
if (renderBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final localPosition = renderBox.globalToLocal(offset);
|
||||
final result = renderBox.paintBounds.contains(localPosition);
|
||||
if (result) {
|
||||
beforeSelection = widget.editorState.selection?.normalized;
|
||||
} else {
|
||||
beforeSelection = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -22,8 +22,8 @@ KeyEventResult _backspaceToTitle({
|
||||
required EditorState editorState,
|
||||
}) {
|
||||
final coverTitleFocusNode = editorState.document.root.context
|
||||
?.read<SharedEditorContext>()
|
||||
.coverTitleFocusNode;
|
||||
?.read<SharedEditorContext?>()
|
||||
?.coverTitleFocusNode;
|
||||
if (coverTitleFocusNode == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@ -108,8 +108,8 @@ KeyEventResult _arrowKeyToTitle({
|
||||
required bool Function(Selection selection) checkSelection,
|
||||
}) {
|
||||
final coverTitleFocusNode = editorState.document.root.context
|
||||
?.read<SharedEditorContext>()
|
||||
.coverTitleFocusNode;
|
||||
?.read<SharedEditorContext?>()
|
||||
?.coverTitleFocusNode;
|
||||
if (coverTitleFocusNode == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@ -110,6 +110,7 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return _MobileMentionPageBlock(
|
||||
view: view,
|
||||
content: state.blockContent,
|
||||
textStyle: widget.textStyle,
|
||||
handleTap: () => handleTap(view),
|
||||
handleDoubleTap: handleDoubleTap,
|
||||
@ -367,6 +368,7 @@ class _NoAccessMentionPageBlock extends StatelessWidget {
|
||||
class _MobileMentionPageBlock extends StatelessWidget {
|
||||
const _MobileMentionPageBlock({
|
||||
required this.view,
|
||||
required this.content,
|
||||
required this.textStyle,
|
||||
required this.handleTap,
|
||||
required this.handleDoubleTap,
|
||||
@ -374,6 +376,7 @@ class _MobileMentionPageBlock extends StatelessWidget {
|
||||
|
||||
final TextStyle? textStyle;
|
||||
final ViewPB view;
|
||||
final String content;
|
||||
final VoidCallback handleTap;
|
||||
final VoidCallback handleDoubleTap;
|
||||
|
||||
@ -385,6 +388,7 @@ class _MobileMentionPageBlock extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: _MentionPageBlockContent(
|
||||
view: view,
|
||||
content: content,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
const _kSmartEditToolbarItemId = 'appflowy.editor.smart_edit';
|
||||
|
||||
@ -39,21 +39,12 @@ class SmartEditActionList extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
bool isAIEnabled = false;
|
||||
bool isAIEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
UserBackendService.getCurrentUserProfile().then((value) {
|
||||
setState(() {
|
||||
isAIEnabled = value.fold(
|
||||
(userProfile) =>
|
||||
userProfile.authenticator == AuthenticatorPB.AppFlowyCloud,
|
||||
(_) => false,
|
||||
);
|
||||
});
|
||||
});
|
||||
isAIEnabled = _isAIEnabled();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -83,12 +74,13 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
),
|
||||
onTap: () {
|
||||
if (isAIEnabled) {
|
||||
keepEditorFocusNotifier.increase();
|
||||
controller.show();
|
||||
} else {
|
||||
showSnackBarMessage(
|
||||
showToastNotification(
|
||||
context,
|
||||
LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
|
||||
showCancel: true,
|
||||
message:
|
||||
LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
@ -161,4 +153,12 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
bool _isAIEnabled() {
|
||||
final documentContext = widget.editorState.document.root.context;
|
||||
if (documentContext == null) {
|
||||
return true;
|
||||
}
|
||||
return !documentContext.read<DocumentBloc>().isLocalMode;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1535,10 +1535,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
|
||||
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
version: "3.1.5"
|
||||
plugin_platform_interface:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -1933,10 +1933,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
|
||||
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.3.0"
|
||||
string_validator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -2238,10 +2238,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
|
||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.1"
|
||||
version: "14.2.5"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -533,5 +533,155 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('calculate selection when turn into', () {
|
||||
// Example:
|
||||
// - bulleted list item 1
|
||||
// - bulleted list item 1-1
|
||||
// - bulleted list item 1-2
|
||||
// - bulleted list item 2
|
||||
// - bulleted list item 2-1
|
||||
// - bulleted list item 2-2
|
||||
// - bulleted list item 3
|
||||
// - bulleted list item 3-1
|
||||
// - bulleted list item 3-2
|
||||
const text = 'bulleted list';
|
||||
const nestedText = 'nested bulleted list';
|
||||
final document = createDocument([
|
||||
bulletedListNode(
|
||||
text: '$text 1',
|
||||
children: [
|
||||
bulletedListNode(text: '$nestedText 1-1'),
|
||||
bulletedListNode(text: '$nestedText 1-2'),
|
||||
],
|
||||
),
|
||||
bulletedListNode(
|
||||
text: '$text 2',
|
||||
children: [
|
||||
bulletedListNode(text: '$nestedText 2-1'),
|
||||
bulletedListNode(text: '$nestedText 2-2'),
|
||||
],
|
||||
),
|
||||
bulletedListNode(
|
||||
text: '$text 3',
|
||||
children: [
|
||||
bulletedListNode(text: '$nestedText 3-1'),
|
||||
bulletedListNode(text: '$nestedText 3-2'),
|
||||
],
|
||||
),
|
||||
]);
|
||||
final editorState = EditorState(document: document);
|
||||
final cubit = BlockActionOptionCubit(
|
||||
editorState: editorState,
|
||||
blockComponentBuilder: {},
|
||||
);
|
||||
|
||||
// case 1: collapsed selection and the selection is in the top level
|
||||
// and tap the turn into button at the [0]
|
||||
final selection1 = Selection.collapsed(
|
||||
Position(path: [0], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([0])!,
|
||||
selection1,
|
||||
),
|
||||
selection1,
|
||||
);
|
||||
|
||||
// case 2: collapsed selection and the selection is in the nested level
|
||||
// and tap the turn into button at the [0]
|
||||
final selection2 = Selection.collapsed(
|
||||
Position(path: [0, 0], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([0])!,
|
||||
selection2,
|
||||
),
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
);
|
||||
|
||||
// case 3, collapsed selection and the selection is in the nested level
|
||||
// and tap the turn into button at the [0, 0]
|
||||
final selection3 = Selection.collapsed(
|
||||
Position(path: [0, 0], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([0, 0])!,
|
||||
selection3,
|
||||
),
|
||||
selection3,
|
||||
);
|
||||
|
||||
// case 4, not collapsed selection and the selection is in the top level
|
||||
// and tap the turn into button at the [0]
|
||||
final selection4 = Selection(
|
||||
start: Position(path: [0], offset: 1),
|
||||
end: Position(path: [1], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([0])!,
|
||||
selection4,
|
||||
),
|
||||
selection4,
|
||||
);
|
||||
|
||||
// case 5, not collapsed selection and the selection is in the nested level
|
||||
// and tap the turn into button at the [0]
|
||||
final selection5 = Selection(
|
||||
start: Position(path: [0, 0], offset: 1),
|
||||
end: Position(path: [0, 1], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([0])!,
|
||||
selection5,
|
||||
),
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
);
|
||||
|
||||
// case 6, not collapsed selection and the selection is in the nested level
|
||||
// and tap the turn into button at the [0, 0]
|
||||
final selection6 = Selection(
|
||||
start: Position(path: [0, 0], offset: 1),
|
||||
end: Position(path: [0, 1], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([0])!,
|
||||
selection6,
|
||||
),
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
);
|
||||
|
||||
// case 7, multiple blocks selection, and tap the turn into button of one of the selected nodes
|
||||
final selection7 = Selection(
|
||||
start: Position(path: [0], offset: 1),
|
||||
end: Position(path: [2], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([1])!,
|
||||
selection7,
|
||||
),
|
||||
selection7,
|
||||
);
|
||||
|
||||
// case 8, multiple blocks selection, and tap the turn into button of one of the non-selected nodes
|
||||
final selection8 = Selection(
|
||||
start: Position(path: [0], offset: 1),
|
||||
end: Position(path: [1], offset: 1),
|
||||
);
|
||||
expect(
|
||||
cubit.calculateTurnIntoSelection(
|
||||
editorState.getNodeAtPath([2])!,
|
||||
selection8,
|
||||
),
|
||||
Selection.collapsed(Position(path: [2])),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@
|
||||
"html": "HTML",
|
||||
"clipboard": "Copy to clipboard",
|
||||
"csv": "CSV",
|
||||
"copyLink": "Copy Link",
|
||||
"copyLink": "Copy link",
|
||||
"publishToTheWeb": "Publish to Web",
|
||||
"publishToTheWebHint": "Create a website with AppFlowy",
|
||||
"publish": "Publish",
|
||||
@ -162,7 +162,7 @@
|
||||
"openNewTab": "Open in a new tab",
|
||||
"moveTo": "Move to",
|
||||
"addToFavorites": "Add to Favorites",
|
||||
"copyLink": "Copy Link",
|
||||
"copyLink": "Copy link",
|
||||
"changeIcon": "Change icon",
|
||||
"collapseAllPages": "Collapse all subpages"
|
||||
},
|
||||
@ -2713,4 +2713,4 @@
|
||||
"refreshNote": "After successful upgrade, click <refresh/> to activate your new features.",
|
||||
"refresh": "here"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user