feat: video block support (#5199)

* feat: video block support

* chore: workaround for ci failing

* chore: test

* chore: check status

* chore: revert apt-get

* chore: add mpv

* chore: add mpv to appimagebuilder

* chore: try again

* chore: update after merge

* ci: remove workaround for microsoft issue

* chore: update editor plugins

* feat: add video block option on mobile

* fix: final changes for menu

* chore: undo cocoapods version
This commit is contained in:
Mathias Mogensen
2024-05-30 03:06:29 +02:00
committed by GitHub
parent 859eaf903b
commit 42e83b3ce9
20 changed files with 890 additions and 51 deletions

View File

@ -5,6 +5,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mo
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/video/video_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/video/video_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@ -110,7 +112,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
showMenu: true,
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
Positioned(
top: 0,
top: 10,
right: 10,
child: ImageMenu(node: node, state: state),
),
@ -180,7 +182,6 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
configuration: configuration,
),
CodeBlockKeys.type: CodeBlockComponentBuilder(
editorState: editorState,
configuration: configuration.copyWith(
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
@ -228,6 +229,16 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration,
),
VideoBlockKeys.type: VideoBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (node, state) => Positioned(
top: 10,
right: 10,
child: VideoMenu(node: node, state: state),
),
placeholderBuilder: (node) => VideoPlaceholder(node: node),
),
};
final builders = {

View File

@ -388,6 +388,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
emojiMenuItem,
autoGeneratorMenuItem,
dateMenuItem,
videoBlockItem(LocaleKeys.document_plugins_video_label.tr()),
];
}

View File

@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
@ -13,7 +15,6 @@ import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
@ -109,10 +110,7 @@ class CustomImageBlockComponentBuilder extends BlockComponentBuilder {
node: node,
showActions: showActions(node),
configuration: configuration,
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
showMenu: showMenu,
menuBuilder: menuBuilder,
);

View File

@ -197,6 +197,15 @@ class _AddBlockMenu extends StatelessWidget {
},
),
// video
TypeOptionMenuItemValue(
value: VideoBlockKeys.type,
backgroundColor: colorMap[VideoBlockKeys.type]!,
text: LocaleKeys.document_plugins_video_label.tr(),
icon: FlowySvgs.m_add_block_video_s,
onTap: (_, __) => _insertBlock(videoBlockNode()),
),
// date
TypeOptionMenuItemValue(
value: ParagraphBlockKeys.type,
@ -287,6 +296,7 @@ class _AddBlockMenu extends StatelessWidget {
NumberedListBlockKeys.type: const Color(0xFFA35F94),
ToggleListBlockKeys.type: const Color(0xFFA35F94),
ImageBlockKeys.type: const Color(0xFFBAAC74),
VideoBlockKeys.type: const Color(0xFFBAAC74),
MentionBlockKeys.type: const Color(0xFF40AAB8),
DividerBlockKeys.type: const Color(0xFF4BB299),
CalloutBlockKeys.type: const Color(0xFF66599B),
@ -303,6 +313,7 @@ class _AddBlockMenu extends StatelessWidget {
NumberedListBlockKeys.type: const Color(0xFFFFB9EF),
ToggleListBlockKeys.type: const Color(0xFFFFB9EF),
ImageBlockKeys.type: const Color(0xFFFDEDA7),
VideoBlockKeys.type: const Color(0xFFFDEDA7),
MentionBlockKeys.type: const Color(0xFF91EAF5),
DividerBlockKeys.type: const Color(0xFF98F4CD),
CalloutBlockKeys.type: const Color(0xFFCABDFF),

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
class UploadVideoMenu extends StatefulWidget {
const UploadVideoMenu({
super.key,
required this.onUrlSubmitted,
this.onSelectedColor,
});
final void Function(String url) onUrlSubmitted;
final void Function(String color)? onSelectedColor;
@override
State<UploadVideoMenu> createState() => _UploadVideoMenuState();
}
class _UploadVideoMenuState extends State<UploadVideoMenu> {
@override
Widget build(BuildContext context) {
final constraints =
PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null;
return Container(
padding: const EdgeInsets.all(8.0),
constraints: constraints,
child: _EmbedUrl(onSubmit: widget.onUrlSubmitted),
);
}
}
class _EmbedUrl extends StatefulWidget {
const _EmbedUrl({required this.onSubmit});
final void Function(String url) onSubmit;
@override
State<_EmbedUrl> createState() => _EmbedUrlState();
}
class _EmbedUrlState extends State<_EmbedUrl> {
bool isUrlValid = true;
String inputText = '';
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyTextField(
hintText: LocaleKeys.document_plugins_video_placeholder.tr(),
onChanged: (value) => inputText = value,
onEditingComplete: submit,
),
if (!isUrlValid) ...[
const VSpace(8),
FlowyText(
LocaleKeys.document_plugins_video_invalidVideoUrl.tr(),
color: Theme.of(context).colorScheme.error,
),
],
const VSpace(8),
SizedBox(
width: 160,
child: FlowyButton(
showDefaultBoxDecorationOnMobile: true,
margin: const EdgeInsets.all(8.0),
text: FlowyText(
LocaleKeys.document_plugins_video_insertVideo.tr(),
textAlign: TextAlign.center,
),
onTap: submit,
),
),
],
);
}
void submit() {
if (checkUrlValidity(inputText)) {
return widget.onSubmit(inputText);
}
setState(() => isUrlValid = false);
}
bool checkUrlValidity(String url) => videoUrlRegex.hasMatch(url);
}

View File

@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.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:flowy_infra_ui/widget/ignore_parent_gesture.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
class VideoMenu extends StatefulWidget {
const VideoMenu({
super.key,
required this.node,
required this.state,
});
final Node node;
final VideoBlockComponentState state;
@override
State<VideoMenu> createState() => _VideoMenuState();
}
class _VideoMenuState extends State<VideoMenu> {
late final String? url = widget.node.attributes[VideoBlockKeys.url];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 32,
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(4.0),
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
),
child: PlatformExtension.isMobile
? MenuBlockButton(
tooltip: LocaleKeys.button_edit.tr(),
iconData: FlowySvgs.more_s,
onTap: showMobileMenu,
)
: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.editor_copyLink.tr(),
iconData: FlowySvgs.copy_s,
onTap: copyVideoLink,
),
const HSpace(4),
_VideoAlignButton(
node: widget.node,
state: widget.state,
),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.delete_s,
onTap: deleteVideo,
),
const HSpace(4),
],
),
);
}
void copyVideoLink() {
if (url != null) {
Clipboard.setData(ClipboardData(text: url!));
showSnackBarMessage(
context,
LocaleKeys.document_plugins_video_copiedToPasteBoard.tr(),
);
}
}
void showMobileMenu() {
final editorState = context.read<EditorState>()
..updateSelectionWithReason(null, extraInfo: {});
final src = widget.node.attributes[VideoBlockKeys.url];
showMobileBottomSheet(
context,
showHeader: true,
showCloseButton: true,
showDragHandle: true,
title: LocaleKeys.document_plugins_action.tr(),
builder: (context) {
return BlockActionBottomSheet(
extendActionWidgets: [
FlowyOptionTile.text(
showTopBorder: false,
text: LocaleKeys.editor_copyLink.tr(),
leftIcon: const FlowySvg(
FlowySvgs.m_field_copy_s,
),
onTap: () async {
context.pop();
showSnackBarMessage(
context,
LocaleKeys.document_plugins_video_copiedToPasteBoard.tr(),
);
await getIt<ClipboardService>().setPlainText(src);
},
),
],
onAction: (action) async {
context.pop();
final transaction = editorState.transaction;
switch (action) {
case BlockActionBottomSheetType.delete:
transaction.deleteNode(widget.node);
break;
case BlockActionBottomSheetType.duplicate:
transaction.insertNode(
widget.node.path.next,
widget.node.copyWith(),
);
break;
case BlockActionBottomSheetType.insertAbove:
case BlockActionBottomSheetType.insertBelow:
final path = action == BlockActionBottomSheetType.insertAbove
? widget.node.path
: widget.node.path.next;
transaction
..insertNode(path, paragraphNode())
..afterSelection = Selection.collapsed(Position(path: path));
break;
default:
}
if (transaction.operations.isNotEmpty) {
await editorState.apply(transaction);
}
},
);
},
);
}
Future<void> deleteVideo() async {
final node = widget.node;
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
transaction.deleteNode(node);
transaction.afterSelection = null;
await editorState.apply(transaction);
}
}
class _VideoAlignButton extends StatefulWidget {
const _VideoAlignButton({
required this.node,
required this.state,
});
final Node node;
final VideoBlockComponentState state;
@override
State<_VideoAlignButton> createState() => _VideoAlignButtonState();
}
const interceptorKey = 'video-align';
class _VideoAlignButtonState extends State<_VideoAlignButton> {
final gestureInterceptor = SelectionGestureInterceptor(
key: interceptorKey,
canTap: (_) => false,
);
String get align =>
widget.node.attributes[VideoBlockKeys.alignment] ?? centerAlignmentKey;
final popoverController = PopoverController();
late final EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
}
@override
void dispose() {
allowMenuClose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnoreParentGestureWidget(
child: AppFlowyPopover(
onClose: allowMenuClose,
controller: popoverController,
windowPadding: const EdgeInsets.all(0),
margin: const EdgeInsets.all(0),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 10),
child: MenuBlockButton(
tooltip: LocaleKeys.document_plugins_optionAction_align.tr(),
iconData: iconFor(align),
),
popupBuilder: (_) {
preventMenuClose();
return _AlignButtons(onAlignChanged: onAlignChanged);
},
),
);
}
void onAlignChanged(String align) {
popoverController.close();
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {VideoBlockKeys.alignment: align});
editorState.apply(transaction);
allowMenuClose();
}
void preventMenuClose() {
widget.state.preventClose = true;
editorState.service.selectionService
.registerGestureInterceptor(gestureInterceptor);
}
void allowMenuClose() {
widget.state.preventClose = false;
editorState.service.selectionService
.unregisterGestureInterceptor(interceptorKey);
}
FlowySvgData iconFor(String alignment) {
switch (alignment) {
case leftAlignmentKey:
return FlowySvgs.align_left_s;
case rightAlignmentKey:
return FlowySvgs.align_right_s;
case centerAlignmentKey:
default:
return FlowySvgs.align_center_s;
}
}
}
class _AlignButtons extends StatelessWidget {
const _AlignButtons({required this.onAlignChanged});
final Function(String align) onAlignChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_plugins_optionAction_left,
iconData: FlowySvgs.align_left_s,
onTap: () => onAlignChanged(leftAlignmentKey),
),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.document_plugins_optionAction_center,
iconData: FlowySvgs.align_center_s,
onTap: () => onAlignChanged(centerAlignmentKey),
),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.document_plugins_optionAction_right,
iconData: FlowySvgs.align_right_s,
onTap: () => onAlignChanged(rightAlignmentKey),
),
const HSpace(4),
],
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Container(width: 1, color: Colors.grey),
);
}
}

View File

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/video/upload_video_menu.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.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:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart';
class VideoPlaceholder extends StatefulWidget {
const VideoPlaceholder({super.key, required this.node});
final Node node;
@override
State<VideoPlaceholder> createState() => VideoPlaceholderState();
}
class VideoPlaceholderState extends State<VideoPlaceholder> {
final controller = PopoverController();
final documentService = DocumentService();
late final editorState = context.read<EditorState>();
bool showLoading = false;
@override
Widget build(BuildContext context) {
final Widget child = DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: FlowyHover(
style: HoverStyle(borderRadius: BorderRadius.circular(4)),
child: SizedBox(
height: 52,
child: Row(
children: [
const HSpace(10),
const Icon(Icons.featured_video_outlined, size: 24),
const HSpace(10),
FlowyText(LocaleKeys.document_plugins_video_emptyLabel.tr()),
],
),
),
),
);
if (PlatformExtension.isDesktopOrWeb) {
return AppFlowyPopover(
controller: controller,
direction: PopoverDirection.bottomWithCenterAligned,
constraints: const BoxConstraints(
maxWidth: 540,
maxHeight: 360,
minHeight: 80,
),
clickHandler: PopoverClickHandler.gestureDetector,
popupBuilder: (_) => UploadVideoMenu(
onUrlSubmitted: (url) {
controller.close();
WidgetsBinding.instance.addPostFrameCallback(
(_) async => updateSrc(url),
);
},
),
child: child,
);
} else {
return MobileBlockActionButtons(
node: widget.node,
editorState: editorState,
child: GestureDetector(
onTap: () {
editorState.updateSelectionWithReason(null, extraInfo: {});
showUploadVideoMenu();
},
child: child,
),
);
}
}
void showUploadVideoMenu() {
if (PlatformExtension.isDesktopOrWeb) {
controller.show();
} else {
showMobileBottomSheet(
context,
title: LocaleKeys.document_plugins_video_label.tr(),
showHeader: true,
showCloseButton: true,
showDragHandle: true,
builder: (context) => Container(
margin: const EdgeInsets.only(top: 12.0),
constraints: const BoxConstraints(
maxHeight: 340,
minHeight: 80,
),
child: UploadVideoMenu(
onUrlSubmitted: (url) async {
context.pop();
await updateSrc(url);
},
),
),
);
}
}
Future<void> updateSrc(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
return;
}
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
VideoBlockKeys.url: url,
});
await editorState.apply(transaction);
}
}