fix: toggle heading issues (#6714)

* fix: pages overflow when selecting homepage

* fix: toggle heading issues

* Revert "fix: pages overflow when selecting homepage"

This reverts commit 156882a9a7e039a38cd206306e19b94eb391f948.

* chore: optimize code logic

* fix: assertion in toggle list

* fix: make the turn into menu and color menu exclusive

* test: add toggle heading test
This commit is contained in:
Lucas 2024-11-05 10:52:22 +08:00 committed by GitHub
parent 54096b391f
commit 82effbf8e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 298 additions and 82 deletions

View File

@ -1,6 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.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';
@ -60,6 +62,50 @@ void main() {
findsNWidgets(6),
);
});
testWidgets('insert toggle heading and convert it to heading',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'toggle heading block test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('# > $_heading1\n');
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.ime.insertText('item 1');
await tester.pumpAndSettle();
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: _heading1.length),
),
);
await tester.tapButton(find.byType(HeadingPopup));
await tester.pumpAndSettle();
expect(
find.byType(HeadingButton),
findsNWidgets(3),
);
// tap the H1 button
await tester.tapButton(find.byType(HeadingButton).at(0));
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node1 = editorState.document.nodeAtPath([0])!;
expect(node1.type, HeadingBlockKeys.type);
expect(node1.attributes[HeadingBlockKeys.level], 1);
final node2 = editorState.document.nodeAtPath([1])!;
expect(node2.type, ParagraphBlockKeys.type);
expect(node2.delta!.toPlainText(), 'item 1');
});
});
}

View File

@ -231,7 +231,6 @@ class _DocumentPageState extends State<DocumentPage>
final Path? path = _getPathFromAction(action, editorState);
if (path != null) {
debugPrint('jump to block: $path');
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);

View File

@ -134,12 +134,19 @@ void _customBlockOptionActions(
);
builder.actionBuilder = (context, state) {
final top = builder.configuration.padding(context.node).top;
final padding = context.node.type == HeadingBlockKeys.type
? EdgeInsets.only(top: top + 8.0)
: EdgeInsets.only(top: top + 2.0);
double top = builder.configuration.padding(context.node).top;
final type = context.node.type;
final level = context.node.attributes[HeadingBlockKeys.level] ?? 0;
if ((type == HeadingBlockKeys.type ||
type == ToggleListBlockKeys.type) &&
level > 0) {
final offset = [14.0, 11.0, 8.0, 6.0, 4.0, 2.0];
top += offset[level - 1];
} else {
top += 2.0;
}
return Padding(
padding: padding,
padding: EdgeInsets.only(top: top),
child: BlockActionList(
blockComponentContext: context,
blockComponentState: state,

View File

@ -9,7 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'drag_to_reorder/draggable_option_button.dart';
class BlockOptionButton extends StatelessWidget {
class BlockOptionButton extends StatefulWidget {
const BlockOptionButton({
super.key,
required this.blockComponentContext,
@ -25,6 +25,16 @@ class BlockOptionButton extends StatelessWidget {
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
@override
State<BlockOptionButton> createState() => _BlockOptionButtonState();
}
class _BlockOptionButtonState extends State<BlockOptionButton> {
// the mutex is used to ensure that only one popover is open at a time
// for example, when the user is selecting the color, the turn into option
// should not be shown.
final mutex = PopoverMutex();
@override
Widget build(BuildContext context) {
final direction =
@ -34,8 +44,8 @@ class BlockOptionButton extends StatelessWidget {
: PopoverDirection.leftWithCenterAligned;
return BlocProvider(
create: (context) => BlockActionOptionCubit(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
editorState: widget.editorState,
blockComponentBuilder: widget.blockComponentBuilder,
),
child: BlocBuilder<BlockActionOptionCubit, BlockActionOptionState>(
builder: (context, _) => PopoverActionList<PopoverAction>(
@ -55,30 +65,41 @@ class BlockOptionButton extends StatelessWidget {
),
buildChild: (controller) => DraggableOptionButton(
controller: controller,
editorState: editorState,
blockComponentContext: blockComponentContext,
blockComponentBuilder: blockComponentBuilder,
editorState: widget.editorState,
blockComponentContext: widget.blockComponentContext,
blockComponentBuilder: widget.blockComponentBuilder,
),
),
),
);
}
@override
void dispose() {
mutex.dispose();
super.dispose();
}
List<PopoverAction> _buildPopoverActions(BuildContext context) {
return actions.map((e) {
return widget.actions.map((e) {
switch (e) {
case OptionAction.divider:
return DividerOptionAction();
case OptionAction.color:
return ColorOptionAction(editorState: editorState);
return ColorOptionAction(
editorState: widget.editorState,
mutex: mutex,
);
case OptionAction.align:
return AlignOptionAction(editorState: editorState);
return AlignOptionAction(editorState: widget.editorState);
case OptionAction.depth:
return DepthOptionAction(editorState: editorState);
return DepthOptionAction(editorState: widget.editorState);
case OptionAction.turnInto:
return TurnIntoOptionAction(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
editorState: widget.editorState,
blockComponentBuilder: widget.blockComponentBuilder,
mutex: mutex,
);
default:
return OptionActionWrapper(e);
@ -88,15 +109,17 @@ class BlockOptionButton extends StatelessWidget {
void _onPopoverBuilder() {
keepEditorFocusNotifier.increase();
blockComponentState.alwaysShowActions = true;
widget.blockComponentState.alwaysShowActions = true;
}
void _onPopoverClosed(BuildContext context) {
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;
});
PopoverContainer.maybeOf(context)?.closeAll();
}
void _onActionSelected(
@ -110,7 +133,7 @@ class BlockOptionButton extends StatelessWidget {
context.read<BlockActionOptionCubit>().handleAction(
action.inner,
blockComponentContext.node,
widget.blockComponentContext.node,
);
controller.close();
}

View File

@ -389,6 +389,14 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
]);
if (afterSelection != null) {
transaction.afterSelection = afterSelection;
} else if (insertedNodes.isNotEmpty) {
// select the blocks
transaction.afterSelection = Selection(
start: Position(path: node.path.child(0)),
end: Position(path: node.path.child(insertedNodes.length - 1)),
);
} else {
transaction.afterSelection = transaction.beforeSelection;
}
await editorState.apply(transaction);

View File

@ -13,10 +13,12 @@ const optionActionColorDefaultColor = 'appflowy_theme_default_color';
class ColorOptionAction extends CustomActionCell {
ColorOptionAction({
required this.editorState,
required this.mutex,
});
final EditorState editorState;
final PopoverController innerController = PopoverController();
final PopoverMutex mutex;
@override
Widget buildWithContext(
@ -24,16 +26,49 @@ class ColorOptionAction extends CustomActionCell {
PopoverController controller,
PopoverMutex? mutex,
) {
return ColorOptionButton(
editorState: editorState,
mutex: this.mutex,
controller: controller,
);
}
}
class ColorOptionButton extends StatefulWidget {
const ColorOptionButton({
super.key,
required this.editorState,
required this.mutex,
required this.controller,
});
final EditorState editorState;
final PopoverMutex mutex;
final PopoverController controller;
@override
State<ColorOptionButton> createState() => _ColorOptionButtonState();
}
class _ColorOptionButtonState extends State<ColorOptionButton> {
final PopoverController innerController = PopoverController();
bool isOpen = false;
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
asBarrier: true,
controller: innerController,
mutex: mutex,
popupBuilder: (context) => _buildColorOptionMenu(
context,
controller,
),
mutex: widget.mutex,
popupBuilder: (context) {
isOpen = true;
return _buildColorOptionMenu(
context,
widget.controller,
);
},
onClose: () => isOpen = false,
direction: PopoverDirection.rightWithCenterAligned,
offset: const Offset(10, 0),
animationDuration: Durations.short3,
beginScaleFactor: 1.0,
beginOpacity: 0.8,
@ -45,7 +80,9 @@ class ColorOptionAction extends CustomActionCell {
),
name: LocaleKeys.document_plugins_optionAction_color.tr(),
onTap: () {
innerController.show();
if (!isOpen) {
innerController.show();
}
},
),
);
@ -55,12 +92,12 @@ class ColorOptionAction extends CustomActionCell {
BuildContext context,
PopoverController controller,
) {
final selection = editorState.selection?.normalized;
final selection = widget.editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
final node = widget.editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
@ -73,11 +110,11 @@ class ColorOptionAction extends CustomActionCell {
Node node,
PopoverController controller,
) {
final selection = editorState.selection?.normalized;
final selection = widget.editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
final node = widget.editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
@ -110,11 +147,11 @@ class ColorOptionAction extends CustomActionCell {
color: AFThemeExtension.of(context).onBackground,
),
onTap: (option, index) async {
final transaction = editorState.transaction;
final transaction = widget.editorState.transaction;
transaction.updateNode(node, {
blockComponentBackgroundColor: option.id,
});
await editorState.apply(transaction);
await widget.editorState.apply(transaction);
innerController.close();
controller.close();

View File

@ -15,11 +15,13 @@ class TurnIntoOptionAction extends CustomActionCell {
TurnIntoOptionAction({
required this.editorState,
required this.blockComponentBuilder,
required this.mutex,
});
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
final PopoverController innerController = PopoverController();
final PopoverMutex mutex;
@override
Widget buildWithContext(
@ -27,21 +29,54 @@ class TurnIntoOptionAction extends CustomActionCell {
PopoverController controller,
PopoverMutex? mutex,
) {
return TurnInfoButton(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
mutex: this.mutex,
);
}
}
class TurnInfoButton extends StatefulWidget {
const TurnInfoButton({
super.key,
required this.editorState,
required this.blockComponentBuilder,
required this.mutex,
});
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
final PopoverMutex mutex;
@override
State<TurnInfoButton> createState() => _TurnInfoButtonState();
}
class _TurnInfoButtonState extends State<TurnInfoButton> {
final PopoverController innerController = PopoverController();
bool isOpen = false;
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
asBarrier: true,
controller: innerController,
mutex: mutex,
popupBuilder: (context) => BlocProvider<BlockActionOptionCubit>(
create: (_) => BlockActionOptionCubit(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
),
child: BlocBuilder<BlockActionOptionCubit, BlockActionOptionState>(
builder: (context, _) => _buildTurnIntoOptionMenu(context),
),
),
mutex: widget.mutex,
popupBuilder: (context) {
isOpen = true;
return BlocProvider<BlockActionOptionCubit>(
create: (context) => BlockActionOptionCubit(
editorState: widget.editorState,
blockComponentBuilder: widget.blockComponentBuilder,
),
child: BlocBuilder<BlockActionOptionCubit, BlockActionOptionState>(
builder: (context, _) => _buildTurnIntoOptionMenu(context),
),
);
},
onClose: () => isOpen = false,
direction: PopoverDirection.rightWithCenterAligned,
offset: const Offset(10, 0),
animationDuration: Durations.short3,
beginScaleFactor: 1.0,
beginOpacity: 0.8,
@ -50,13 +85,17 @@ class TurnIntoOptionAction extends CustomActionCell {
// todo(lucas): replace the svg with the correct one
leftIcon: const FlowySvg(FlowySvgs.turninto_s),
name: LocaleKeys.document_plugins_optionAction_turnInto.tr(),
onTap: innerController.show,
onTap: () {
if (!isOpen) {
innerController.show();
}
},
),
);
}
Widget _buildTurnIntoOptionMenu(BuildContext context) {
final selection = editorState.selection?.normalized;
final selection = widget.editorState.selection?.normalized;
// the selection may not be collapsed, for example, if a block contains some children,
// the selection will be the start from the current block and end at the last child block.
// we should take care of this case:
@ -66,7 +105,7 @@ class TurnIntoOptionAction extends CustomActionCell {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
final node = widget.editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
@ -78,7 +117,7 @@ class TurnIntoOptionAction extends CustomActionCell {
}
bool _hasNonSupportedTypes(Selection selection) {
final nodes = editorState.getNodesInSelection(selection);
final nodes = widget.editorState.getNodesInSelection(selection);
if (nodes.isEmpty) {
return false;
}

View File

@ -49,7 +49,7 @@ final headingsToolbarItem = ToolbarItem(
],
),
);
return _HeadingPopup(
return HeadingPopup(
currentLevel: isHighlight ? level : -1,
highlightColor: highlightColor,
child: child,
@ -60,9 +60,9 @@ final headingsToolbarItem = ToolbarItem(
? ParagraphBlockKeys.type
: HeadingBlockKeys.type;
await editorState.formatNode(
selection,
(node) => node.copyWith(
if (type == HeadingBlockKeys.type) {
// from paragraph to heading
final newNode = node.copyWith(
type: type,
attributes: {
HeadingBlockKeys.level: newLevel,
@ -72,15 +72,41 @@ final headingsToolbarItem = ToolbarItem(
node.attributes[blockComponentTextDirection],
blockComponentDelta: delta,
},
),
);
);
final children = node.children.map((child) => child.copyWith());
final transaction = editorState.transaction;
transaction.insertNodes(
selection.start.path.next,
[newNode, ...children],
);
transaction.deleteNode(node);
await editorState.apply(transaction);
} else {
// from heading to paragraph
await editorState.formatNode(
selection,
(node) => node.copyWith(
type: type,
attributes: {
HeadingBlockKeys.level: newLevel,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: delta,
},
),
);
}
},
);
},
);
class _HeadingPopup extends StatelessWidget {
const _HeadingPopup({
class HeadingPopup extends StatelessWidget {
const HeadingPopup({
super.key,
required this.currentLevel,
required this.highlightColor,
required this.onLevelChanged,
@ -144,7 +170,7 @@ class _HeadingButtons extends StatelessWidget {
final svg = data.$1;
final message = data.$2;
return [
_HeadingButton(
HeadingButton(
icon: svg,
tooltip: message,
onTap: () => onLevelChanged(index + 1),
@ -163,8 +189,9 @@ class _HeadingButtons extends StatelessWidget {
}
}
class _HeadingButton extends StatelessWidget {
const _HeadingButton({
class HeadingButton extends StatelessWidget {
const HeadingButton({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,

View File

@ -1,4 +1,5 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// Convert '# ' to bulleted list
@ -35,6 +36,7 @@ CharacterShortcutEvent customFormatSignToHeading = CharacterShortcutEvent(
level: numberOfSign,
delta: delta.compose(Delta()..delete(numberOfSign)),
collapsed: collapsed ?? false,
children: node.children.map((child) => child.copyWith()),
),
];
}

View File

@ -180,6 +180,31 @@ class _ToggleListBlockComponentWidgetState
: buildComponentWithChildren(context);
}
@override
Widget buildComponentWithChildren(BuildContext context) {
return Stack(
children: [
if (backgroundColor != Colors.transparent)
Positioned.fill(
left: cachedLeft,
top: padding.top,
child: Container(
width: double.infinity,
color: backgroundColor,
),
),
NestedListWidget(
indentPadding: indentPadding,
child: buildComponent(context),
children: editorState.renderer.buildList(
context,
widget.node.children,
),
),
],
);
}
@override
Widget buildComponent(
BuildContext context, {
@ -190,9 +215,6 @@ class _ToggleListBlockComponentWidgetState
);
Widget child = Container(
color: withBackgroundColor || backgroundColor != Colors.transparent
? backgroundColor
: null,
width: double.infinity,
alignment: alignment,
child: Row(
@ -208,12 +230,6 @@ class _ToggleListBlockComponentWidgetState
),
);
child = Padding(
key: blockComponentKey,
padding: padding,
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
@ -225,6 +241,18 @@ class _ToggleListBlockComponentWidgetState
child: child,
);
child = Padding(
padding: padding,
child: Container(
key: blockComponentKey,
color: withBackgroundColor ||
(backgroundColor != Colors.transparent && collapsed)
? backgroundColor
: null,
child: child,
),
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
@ -293,16 +321,16 @@ class _ToggleListBlockComponentWidgetState
minHeight: buttonHeight,
),
padding: EdgeInsets.only(top: top, right: 4.0),
child: AnimatedRotation(
turns: collapsed ? 0.0 : 0.25,
duration: const Duration(milliseconds: 200),
child: FlowyIconButton(
width: 20.0,
icon: const Icon(
child: FlowyIconButton(
width: 20.0,
onPressed: onCollapsed,
icon: AnimatedRotation(
turns: collapsed ? 0.0 : 0.25,
duration: const Duration(milliseconds: 200),
child: const Icon(
Icons.arrow_right,
size: 18.0,
),
onPressed: onCollapsed,
),
),
);

View File

@ -61,8 +61,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: eccb244
resolved-ref: eccb24452a451c734bc2aeae56c1fd81398d849c
ref: "2903792"
resolved-ref: "2903792fa319e1b4077164eeb684f6e8d1c63e27"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"

View File

@ -172,7 +172,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "eccb244"
ref: "2903792"
appflowy_editor_plugins:
git:

View File

@ -2495,7 +2495,7 @@
"spaceIcon": "Icon",
"dangerZone": "Danger Zone",
"unableToDeleteLastSpace": "Unable to delete the last Space",
"unableToDeleteSpaceNotCreatedByYou": "Unable to delete Spaces created by others",
"unableToDeleteSpaceNotCreatedByYou": "Unable to delete spaces created by others",
"enableSpacesForYourWorkspace": "Enable Spaces for your workspace",
"title": "Spaces",
"defaultSpaceName": "General",