feat: support dragging the block to reorder (#6285)

* feat: support dragging the block to reorder

* feat: render feedback widget

* feat: add drag to move translation

* fix: the feedback widget doesn't update after node changed

* feat: render table placeholder

* feat: implement auto scroll when dragging to edge

* chore: add back the drop images/files feature

* chore: code refactor

* feat: exclude image and file block

* feat: exclude image gallery and database block
This commit is contained in:
Lucas 2024-09-16 17:01:02 +08:00 committed by GitHub
parent 129db6925c
commit d67d77f442
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 383 additions and 95 deletions

View File

@ -171,7 +171,7 @@ class _DocumentPageState extends State<DocumentPage>
.getDropTargetRenderData(details.globalPosition);
if (data != null &&
data.dropTarget != null &&
data.dropPath != null &&
// We implement custom Drop logic for image blocks, this is
// how we can exclude them from the Drop Target
@ -184,14 +184,28 @@ class _DocumentPageState extends State<DocumentPage>
}
},
onDragDone: (details) async {
state.editorState!.selectionService.removeDropTarget();
final editorState = state.editorState;
if (editorState == null) {
return;
}
final data = state.editorState!.selectionService
editorState.selectionService.removeDropTarget();
final data = editorState.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null) {
if (data.cursorNode != null) {
if (_excludeFromDropTarget.contains(data.cursorNode?.type)) {
final cursorNode = data.cursorNode;
final dropPath = data.dropPath;
if (cursorNode != null && dropPath != null) {
if (_excludeFromDropTarget.contains(cursorNode.type)) {
return;
}
final node = editorState.getNodeAtPath(dropPath);
if (node == null) {
return;
}
@ -209,14 +223,15 @@ class _DocumentPageState extends State<DocumentPage>
}
}
await editorState!.dropImages(
data.dropTarget!,
await editorState.dropImages(
node,
imageFiles,
widget.view.id,
isLocalMode,
);
await editorState!.dropFiles(
data.dropTarget!,
await editorState.dropFiles(
node,
otherFiles,
widget.view.id,
isLocalMode,

View File

@ -314,6 +314,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
blockComponentContext: context,
blockComponentState: state,
editorState: editorState,
blockComponentBuilder: builders,
actions: actions,
showSlashMenu: slashMenuItems != null
? () => customSlashCommand(

View File

@ -1,8 +1,9 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class BlockActionList extends StatelessWidget {
const BlockActionList({
@ -12,6 +13,7 @@ class BlockActionList extends StatelessWidget {
required this.editorState,
required this.actions,
required this.showSlashMenu,
required this.blockComponentBuilder,
});
final BlockComponentContext blockComponentContext;
@ -19,6 +21,7 @@ class BlockActionList extends StatelessWidget {
final List<OptionAction> actions;
final VoidCallback showSlashMenu;
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
@override
Widget build(BuildContext context) {
@ -31,14 +34,15 @@ class BlockActionList extends StatelessWidget {
editorState: editorState,
showSlashMenu: showSlashMenu,
),
const SizedBox(width: 4.0),
const HSpace(4.0),
BlockOptionButton(
blockComponentContext: blockComponentContext,
blockComponentState: blockComponentState,
actions: actions,
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
),
const SizedBox(width: 4.0),
const HSpace(4.0),
],
);
}

View File

@ -1,48 +1,59 @@
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/option_action.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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';
import 'package:provider/provider.dart';
class BlockOptionButton extends StatelessWidget {
import 'drag_to_reorder/draggable_option_button.dart';
class BlockOptionButton extends StatefulWidget {
const BlockOptionButton({
super.key,
required this.blockComponentContext,
required this.blockComponentState,
required this.actions,
required this.editorState,
required this.blockComponentBuilder,
});
final BlockComponentContext blockComponentContext;
final BlockComponentActionState blockComponentState;
final List<OptionAction> actions;
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
@override
Widget build(BuildContext context) {
final popoverActions = actions.map((e) {
State<BlockOptionButton> createState() => _BlockOptionButtonState();
}
class _BlockOptionButtonState extends State<BlockOptionButton> {
late final List<PopoverAction> popoverActions;
@override
void initState() {
super.initState();
popoverActions = widget.actions.map((e) {
switch (e) {
case OptionAction.divider:
return DividerOptionAction();
case OptionAction.color:
return ColorOptionAction(editorState: editorState);
return ColorOptionAction(editorState: widget.editorState);
case OptionAction.align:
return AlignOptionAction(editorState: editorState);
return AlignOptionAction(editorState: widget.editorState);
case OptionAction.depth:
return DepthOptionAction(editorState: editorState);
return DepthOptionAction(editorState: widget.editorState);
default:
return OptionActionWrapper(e);
}
}).toList();
}
@override
Widget build(BuildContext context) {
return PopoverActionList<PopoverAction>(
popoverMutex: PopoverMutex(),
direction:
@ -53,13 +64,13 @@ class BlockOptionButton extends StatelessWidget {
actions: popoverActions,
onPopupBuilder: () {
keepEditorFocusNotifier.increase();
blockComponentState.alwaysShowActions = true;
widget.blockComponentState.alwaysShowActions = true;
},
onClosed: () {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selectionType = null;
editorState.selection = null;
blockComponentState.alwaysShowActions = false;
widget.editorState.selectionType = null;
widget.editorState.selection = null;
widget.blockComponentState.alwaysShowActions = false;
keepEditorFocusNotifier.decrease();
});
},
@ -69,62 +80,18 @@ class BlockOptionButton extends StatelessWidget {
controller.close();
}
},
buildChild: (controller) => _buildOptionButton(context, controller),
);
}
Widget _buildOptionButton(
BuildContext context,
PopoverController controller,
) {
return BlockActionButton(
svg: FlowySvgs.drag_element_s,
richMessage: TextSpan(
children: [
TextSpan(
// todo: customize the color to highlight the text.
text: LocaleKeys.document_plugins_optionAction_click.tr(),
style: context.tooltipTextStyle(),
),
TextSpan(
text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(),
style: context.tooltipTextStyle(),
),
],
buildChild: (controller) => DraggableOptionButton(
controller: controller,
editorState: widget.editorState,
blockComponentContext: widget.blockComponentContext,
blockComponentBuilder: widget.blockComponentBuilder,
),
onTap: () {
controller.show();
// update selection
_updateBlockSelection();
},
);
}
void _updateBlockSelection() {
final startNode = blockComponentContext.node;
var endNode = startNode;
while (endNode.children.isNotEmpty) {
endNode = endNode.children.last;
}
final start = Position(path: startNode.path);
final end = endNode.selectable?.end() ??
Position(
path: endNode.path,
offset: endNode.delta?.length ?? 0,
);
editorState.selectionType = SelectionType.block;
editorState.selection = Selection(
start: start,
end: end,
);
}
void _onSelectAction(BuildContext context, OptionAction action) {
final node = blockComponentContext.node;
final transaction = editorState.transaction;
final node = widget.blockComponentContext.node;
final transaction = widget.editorState.transaction;
switch (action) {
case OptionAction.delete:
transaction.deleteNode(node);
@ -146,7 +113,7 @@ class BlockOptionButton extends StatelessWidget {
case OptionAction.depth:
throw UnimplementedError();
}
editorState.apply(transaction);
widget.editorState.apply(transaction);
}
void _duplicateBlock(
@ -156,8 +123,7 @@ class BlockOptionButton extends StatelessWidget {
) {
// 1. verify the node integrity
final type = node.type;
final builder =
context.read<EditorState>().renderer.blockComponentBuilder(type);
final builder = widget.editorState.renderer.blockComponentBuilder(type);
if (builder == null) {
Log.error('Block type $type is not supported');
@ -184,8 +150,7 @@ class BlockOptionButton extends StatelessWidget {
Node copiedNode = node.copyWith();
final type = node.type;
final builder =
context.read<EditorState>().renderer.blockComponentBuilder(type);
final builder = widget.editorState.renderer.blockComponentBuilder(type);
if (builder == null) {
Log.error('Block type $type is not supported');

View File

@ -0,0 +1,292 @@
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_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:provider/provider.dart';
class DraggableOptionButton extends StatefulWidget {
const DraggableOptionButton({
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<DraggableOptionButton> createState() => _DraggableOptionButtonState();
}
class _DraggableOptionButtonState extends State<DraggableOptionButton> {
late Node node;
late BlockComponentContext blockComponentContext;
Offset? globalPosition;
@override
void initState() {
super.initState();
// copy the node to avoid the node in document being updated
node = widget.blockComponentContext.node.copyWith();
}
@override
void dispose() {
node.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Draggable<Node>(
data: node,
feedback: _OptionButtonFeedback(
controller: widget.controller,
editorState: widget.editorState,
blockComponentContext: widget.blockComponentContext,
blockComponentBuilder: widget.blockComponentBuilder,
),
onDragStarted: () {
widget.editorState.selectionService.removeDropTarget();
},
onDragUpdate: (details) {
widget.editorState.selectionService
.renderDropTargetForOffset(details.globalPosition);
globalPosition = details.globalPosition;
// auto scroll the page when the drag position is at the edge of the screen
widget.editorState.scrollService?.startAutoScroll(
details.localPosition,
);
},
onDragEnd: (details) {
widget.editorState.selectionService.removeDropTarget();
if (globalPosition == null) {
return;
}
final data = widget.editorState.selectionService
.getDropTargetRenderData(globalPosition!);
final acceptedPath = data?.dropPath;
_moveNodeToNewPosition(node, acceptedPath);
},
child: _OptionButton(
controller: widget.controller,
editorState: widget.editorState,
blockComponentContext: widget.blockComponentContext,
),
);
}
Future<void> _moveNodeToNewPosition(Node node, Path? acceptedPath) async {
if (acceptedPath == null) {
Log.info('acceptedPath is null');
return;
}
Log.info('move node($node) to path($acceptedPath)');
final transaction = widget.editorState.transaction;
// use the node in document instead of the local node
transaction.moveNode(acceptedPath, widget.blockComponentContext.node);
await widget.editorState.apply(transaction);
}
}
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: Provider.value(
value: widget.editorState,
child: builder.build(blockComponentContext),
),
);
}
void _updateBlockComponentContext() {
setState(() => _setupLockComponentContext());
}
void _setupLockComponentContext() {
node = widget.blockComponentContext.node.copyWith();
blockComponentContext = BlockComponentContext(
widget.blockComponentContext.buildContext,
node,
);
}
}
class _OptionButton extends StatelessWidget {
const _OptionButton({
required this.controller,
required this.editorState,
required this.blockComponentContext,
});
final PopoverController controller;
final EditorState editorState;
final BlockComponentContext blockComponentContext;
@override
Widget build(BuildContext context) {
return BlockActionButton(
svg: FlowySvgs.drag_element_s,
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: () {
controller.show();
// update selection
_updateBlockSelection();
},
);
}
void _updateBlockSelection() {
final startNode = blockComponentContext.node;
var endNode = startNode;
while (endNode.children.isNotEmpty) {
endNode = endNode.children.last;
}
final start = Position(path: startNode.path);
final end = endNode.selectable?.end() ??
Position(
path: endNode.path,
offset: endNode.delta?.length ?? 0,
);
editorState.selectionType = SelectionType.block;
editorState.selection = Selection(
start: start,
end: end,
);
}
}

View File

@ -1,8 +1,6 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
@ -10,6 +8,7 @@ import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:string_validator/string_validator.dart';
@ -61,7 +60,7 @@ class _ResizableImageState extends State<ResizableImage> {
void initState() {
super.initState();
imageWidth = widget.width;
_userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
_userProfilePB = context.read<DocumentBloc?>()?.state.userProfilePB;
}
@override

View File

@ -73,16 +73,19 @@ class FavoriteMenuBloc extends Bloc<FavoriteMenuEvent, FavoriteMenuState> {
(List<ViewPB>, List<ViewPB>, List<ViewPB>, List<ViewPB>) _getViews(
RepeatedFavoriteViewPB source,
) {
final now = DateTime.now();
final List<ViewPB> views = source.items.map((v) => v.item).toList();
final List<ViewPB> todayViews = [];
final List<ViewPB> thisWeekViews = [];
final List<ViewPB> otherViews = [];
for (final favoriteView in source.items) {
final view = favoriteView.item;
final date = DateTime.fromMillisecondsSinceEpoch(
favoriteView.timestamp.toInt() * 1000,
);
final diff = DateTime.now().difference(date).inDays;
final diff = now.difference(date).inDays;
if (diff == 0) {
todayViews.add(view);
} else if (diff < 7) {
@ -91,6 +94,7 @@ class FavoriteMenuBloc extends Bloc<FavoriteMenuEvent, FavoriteMenuState> {
otherViews.add(view);
}
}
return (views, todayViews, thisWeekViews, otherViews);
}
}

View File

@ -49,21 +49,27 @@ class FlowyTooltip extends StatelessWidget {
extension FlowyToolTipExtension on BuildContext {
double tooltipFontSize() => 14.0;
double tooltipHeight() => 20.0 / tooltipFontSize();
double tooltipHeight({double? fontSize}) =>
20.0 / (fontSize ?? tooltipFontSize());
Color tooltipFontColor() => Theme.of(this).brightness == Brightness.light
? Colors.white
: Colors.black;
TextStyle? tooltipTextStyle({Color? fontColor}) {
TextStyle? tooltipTextStyle({Color? fontColor, double? fontSize}) {
return Theme.of(this).textTheme.bodyMedium?.copyWith(
color: fontColor ?? tooltipFontColor(),
fontSize: tooltipFontSize(),
fontSize: fontSize ?? tooltipFontSize(),
fontWeight: FontWeight.w400,
height: tooltipHeight(),
height: tooltipHeight(fontSize: fontSize),
leadingDistribution: TextLeadingDistribution.even,
);
}
TextStyle? tooltipHintTextStyle({double? fontSize}) => tooltipTextStyle(
fontColor: tooltipFontColor().withOpacity(0.7),
fontSize: fontSize,
);
Color tooltipBackgroundColor() =>
Theme.of(this).brightness == Brightness.light
? const Color(0xFF1D2129)

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "200b572"
resolved-ref: "200b572fa37cb9802b0aa61cf05045a24800a905"
ref: "5d1d311"
resolved-ref: "5d1d311a2c22adc8c42707d6008a4408150c869c"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "3.3.0"

View File

@ -196,7 +196,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "200b572"
ref: "5d1d311"
appflowy_editor_plugins:
git:

View File

@ -1615,6 +1615,8 @@
"optionAction": {
"click": "Click",
"toOpenMenu": " to open menu",
"drag": "Drag",
"toMove": " to move",
"delete": "Delete",
"duplicate": "Duplicate",
"turnInto": "Turn into",