diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart index 726d959791..c2c575f5cf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart @@ -12,6 +12,7 @@ class DocumentAppearance { const DocumentAppearance({ required this.fontSize, required this.fontFamily, + required this.codeFontFamily, this.cursorColor, this.selectionColor, this.defaultTextDirection, @@ -19,6 +20,7 @@ class DocumentAppearance { final double fontSize; final String fontFamily; + final String codeFontFamily; final Color? cursorColor; final Color? selectionColor; final String? defaultTextDirection; @@ -31,6 +33,7 @@ class DocumentAppearance { DocumentAppearance copyWith({ double? fontSize, String? fontFamily, + String? codeFontFamily, Color? cursorColor, Color? selectionColor, String? defaultTextDirection, @@ -41,6 +44,7 @@ class DocumentAppearance { return DocumentAppearance( fontSize: fontSize ?? this.fontSize, fontFamily: fontFamily ?? this.fontFamily, + codeFontFamily: codeFontFamily ?? this.codeFontFamily, cursorColor: cursorColorIsNull ? null : cursorColor ?? this.cursorColor, selectionColor: selectionColorIsNull ? null : selectionColor ?? this.selectionColor, @@ -57,6 +61,7 @@ class DocumentAppearanceCubit extends Cubit { const DocumentAppearance( fontSize: 16.0, fontFamily: builtInFontFamily, + codeFontFamily: builtInCodeFontFamily, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index e8a5f101f9..88c77bd992 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -1,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; @@ -7,8 +10,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; Map getEditorBuilderMap({ required BuildContext context, @@ -151,11 +152,7 @@ Map getEditorBuilderMap({ textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), ), - padding: const EdgeInsets.only( - left: 30, - right: 30, - bottom: 36, - ), + padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34), ), AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(), SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 0822f0384e..9dc429304e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,7 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart'; @@ -20,8 +24,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; final List commandShortcutEvents = [ @@ -143,10 +145,15 @@ class _AppFlowyEditorPageState extends State { style: styleCustomizer.selectionMenuStyleBuilder(), ), + customFormatGreaterEqual, + ...standardCharacterShortcutEvents ..removeWhere( - (element) => element == slashCommand, - ), // remove the default slash command. + (shortcut) => [ + slashCommand, // Remove default slash command + formatGreaterEqual, // Overridden by customFormatGreaterEqual + ].contains(shortcut), + ), /// Inline Actions /// - Reminder diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart new file mode 100644 index 0000000000..8ead5f9533 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +const _greater = '>'; +const _equals = '='; +const _arrow = '⇒'; + +/// format '=' + '>' into an ⇒ +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent customFormatGreaterEqual = CharacterShortcutEvent( + key: 'format = + > into ⇒', + character: _greater, + handler: (editorState) async => _handleDoubleCharacterReplacement( + editorState: editorState, + character: _greater, + replacement: _arrow, + prefixCharacter: _equals, + ), +); + +/// If [prefixCharacter] is null or empty, [character] is used +Future _handleDoubleCharacterReplacement({ + required EditorState editorState, + required String character, + required String replacement, + String? prefixCharacter, +}) async { + assert(character.length == 1); + + final selection = editorState.selection; + if (selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || + delta == null || + delta.isEmpty || + node.type == CodeBlockKeys.type) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final expectedPrevious = + prefixCharacter?.isEmpty ?? true ? character : prefixCharacter; + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != expectedPrevious) { + return false; + } + + final replace = editorState.transaction + ..replaceText( + node, + selection.end.offset - 1, + 1, + replacement, + ); + + await editorState.apply(replace); + + return true; + } + + return false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart index 8832224367..c56fbd09e9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; class SelectableItemListMenu extends StatelessWidget { const SelectableItemListMenu({ @@ -8,24 +9,29 @@ class SelectableItemListMenu extends StatelessWidget { required this.items, required this.selectedIndex, required this.onSelected, + this.shrinkWrap = false, }); final List items; final int selectedIndex; final void Function(int) onSelected; + /// shrinkWrapping is useful in cases where you have a list of + /// limited amount of items. It will make the list take the minimum + /// amount of space required to show all the items. + /// + final bool shrinkWrap; + @override Widget build(BuildContext context) { return ListView.builder( - itemBuilder: (context, index) { - final item = items[index]; - return SelectableItem( - isSelected: index == selectedIndex, - item: item, - onTap: () => onSelected(index), - ); - }, + shrinkWrap: shrinkWrap, itemCount: items.length, + itemBuilder: (context, index) => SelectableItem( + isSelected: index == selectedIndex, + item: items[index], + onTap: () => onSelected(index), + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_component.dart index 4b768ebe09..beaab3c714 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_component.dart @@ -1,16 +1,22 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + +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/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.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_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:go_router/go_router.dart'; import 'package:highlight/highlight.dart' as highlight; import 'package:highlight/languages/all.dart'; @@ -114,6 +120,8 @@ SelectionMenuItem codeBlockItem = SelectionMenuItem.node( replace: (_, node) => node.delta?.isEmpty ?? false, ); +const _interceptorKey = 'code-block-interceptor'; + class CodeBlockComponentBuilder extends BlockComponentBuilder { CodeBlockComponentBuilder({ super.configuration, @@ -167,12 +175,11 @@ class _CodeBlockComponentWidgetState extends State BlockComponentTextDirectionMixin { // the key used to forward focus to the richtext child @override - final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); + final forwardKey = GlobalKey(debugLabel: 'code_flowy_rich_text'); @override - GlobalKey> blockComponentKey = GlobalKey( - debugLabel: CodeBlockKeys.type, - ); + GlobalKey> blockComponentKey = + GlobalKey(debugLabel: CodeBlockKeys.type); @override BlockComponentConfiguration get configuration => widget.configuration; @@ -183,50 +190,108 @@ class _CodeBlockComponentWidgetState extends State @override Node get node => widget.node; - final popoverController = PopoverController(); - @override late final editorState = context.read(); + final popoverController = PopoverController(); + final scrollController = ScrollController(); + + // We use this to calculate the position of the cursor in the code block + // for automatic scrolling. + final codeBlockKey = GlobalKey(); + String? get language => node.attributes[CodeBlockKeys.language] as String?; String? autoDetectLanguage; + bool isSelected = false; + bool isHovering = false; + bool canPanStart = true; + + late final interceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (_) => canPanStart && !isSelected, + canPanStart: (_) => canPanStart && !isSelected, + ); + + late final StreamSubscription<(TransactionTime, Transaction)> + transactionSubscription; + + @override + void initState() { + super.initState(); + editorState.selectionService.registerGestureInterceptor(interceptor); + editorState.selectionNotifier.addListener(calculateScrollPosition); + transactionSubscription = editorState.transactionStream.listen((event) { + if (event.$2.operations.any((op) => op.path.equals(node.path))) { + calculateScrollPosition(); + } + }); + } + + @override + void dispose() { + scrollController.dispose(); + editorState.selectionService.currentSelection + .removeListener(calculateScrollPosition); + editorState.selectionService.unregisterGestureInterceptor(_interceptorKey); + transactionSubscription.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); - Widget child = Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: AFThemeExtension.of(context).calloutBGColor, - ), - width: MediaQuery.of(context).size.width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - textDirection: textDirection, - children: [ - _buildSwitchLanguageButton(context), - _buildCodeBlock(context, textDirection), - ], + + Widget child = MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: AFThemeExtension.of(context).calloutBGColor, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + children: [ + MouseRegion( + onEnter: (_) => setState(() => canPanStart = false), + onExit: (_) => setState(() => canPanStart = true), + child: Opacity( + opacity: isHovering || isSelected ? 1.0 : 0.0, + child: Row( + children: [ + _LanguageSelector( + controller: popoverController, + language: language, + isSelected: isSelected, + onLanguageSelected: updateLanguage, + onMenuOpen: () => isSelected = true, + onMenuClose: () => setState(() => isSelected = false), + ), + const Spacer(), + _CopyButton(node: node), + ], + ), + ), + ), + _buildCodeBlock(context, textDirection), + ], + ), ), ); - child = Padding( - key: blockComponentKey, - padding: padding, - child: child, - ); + child = Padding(key: blockComponentKey, padding: padding, child: child); child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, - supportTypes: const [ - BlockSelectionType.block, - ], + supportTypes: const [BlockSelectionType.block], child: child, ); @@ -260,97 +325,111 @@ class _CodeBlockComponentWidgetState extends State language: language, autoDetection: language == null, ); + autoDetectLanguage = language ?? result.language; final codeNodes = result.nodes; if (codeNodes == null) { throw Exception('Code block parse error.'); } + final codeTextSpans = _convert(codeNodes, isLightMode: isLightMode); + final linesOfCode = delta.toPlainText().split('\n').length; + return Padding( padding: widget.padding, - child: AppFlowyRichText( - key: forwardKey, - delegate: this, - node: widget.node, - editorState: editorState, - placeholderText: placeholderText, - lineHeight: 1.5, - textSpanDecorator: (textSpan) => TextSpan( - style: textStyle, - children: codeTextSpans, - ), - placeholderTextSpanDecorator: (textSpan) => TextSpan( - style: textStyle, - ), - textDirection: textDirection, - cursorColor: editorState.editorStyle.cursorColor, - selectionColor: editorState.editorStyle.selectionColor, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _LinesOfCodeNumbers( + linesOfCode: linesOfCode, + textStyle: textStyle, + ), + Flexible( + child: SingleChildScrollView( + key: codeBlockKey, + controller: scrollController, + physics: const ClampingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: AppFlowyRichText( + key: forwardKey, + delegate: this, + node: widget.node, + editorState: editorState, + placeholderText: placeholderText, + lineHeight: 1.5, + textSpanDecorator: (_) => TextSpan( + style: textStyle, + children: codeTextSpans, + ), + placeholderTextSpanDecorator: (textSpan) => textSpan, + textDirection: textDirection, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + ), + ), + ), + ], ), ); } - Widget _buildSwitchLanguageButton(BuildContext context) { - const maxWidth = 100.0; - - Widget child = Container( - width: maxWidth, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only( - top: 4.0, - left: 4.0, - bottom: 12.0, - ), - child: FlowyTextButton( - '${language?.capitalize() ?? 'Auto'} ', - constraints: const BoxConstraints(maxWidth: maxWidth), - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () async { - if (PlatformExtension.isMobile) { - final language = await context.push( - MobileCodeLanguagePickerScreen.routeName, - ); - if (language != null) { - unawaited(updateLanguage(language)); - } - } - }, - ), - ); - - if (PlatformExtension.isDesktopOrWeb) { - child = AppFlowyPopover( - controller: popoverController, - child: child, - popupBuilder: (BuildContext context) { - return SelectableItemListMenu( - items: - codeBlockSupportedLanguages.map((e) => e.capitalize()).toList(), - selectedIndex: codeBlockSupportedLanguages.indexOf(language ?? ''), - onSelected: (index) { - updateLanguage(codeBlockSupportedLanguages[index]); - popoverController.close(); - }, - ); - }, - ); - } - - return child; - } - Future updateLanguage(String language) async { final transaction = editorState.transaction - ..updateNode(node, { - CodeBlockKeys.language: language == 'auto' ? null : language, - }) - ..afterSelection = Selection.collapsed( - Position(path: node.path, offset: node.delta?.length ?? 0), + ..updateNode( + node, + {CodeBlockKeys.language: language == 'auto' ? null : language}, ); await editorState.apply(transaction); } + void calculateScrollPosition() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final selection = editorState.selection; + if (!mounted || selection == null || !selection.isCollapsed) { + return; + } + + final nodes = editorState.getNodesInSelection(selection); + if (nodes.isEmpty || nodes.length > 1) { + return; + } + + final selectedNode = nodes.first; + if (selectedNode.path.equals(widget.node.path)) { + final renderBox = + codeBlockKey.currentContext?.findRenderObject() as RenderBox?; + final rects = editorState.selectionRects(); + if (renderBox == null || rects.isEmpty) { + return; + } + + final codeBlockOffset = renderBox.localToGlobal(Offset.zero); + final codeBlockSize = renderBox.size; + + final cursorRect = rects.first; + final cursorRelativeOffset = cursorRect.center - codeBlockOffset; + + // If the relative position of the cursor is less than 1, and the scrollController + // is not at offset 0, then we need to scroll to the left to make cursor visible. + if (cursorRelativeOffset.dx < 1 && scrollController.offset > 0) { + scrollController + .jumpTo(scrollController.offset + cursorRelativeOffset.dx - 1); + + // If the relative position of the cursor is greater than the width of the code block, + // then we need to scroll to the right to make cursor visible. + } else if (cursorRelativeOffset.dx > codeBlockSize.width - 1) { + scrollController.jumpTo( + scrollController.offset + + cursorRelativeOffset.dx - + codeBlockSize.width + + 1, + ); + } + } + }); + } + // Copy from flutter.highlight package. // https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart List _convert( @@ -358,7 +437,7 @@ class _CodeBlockComponentWidgetState extends State bool isLightMode = true, }) { final List spans = []; - var currentSpans = spans; + List currentSpans = spans; final List> stack = []; final codeblockTheme = @@ -401,3 +480,221 @@ class _CodeBlockComponentWidgetState extends State return spans; } } + +class _LinesOfCodeNumbers extends StatelessWidget { + const _LinesOfCodeNumbers({ + required this.linesOfCode, + required this.textStyle, + }); + + final int linesOfCode; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int i = 1; i <= linesOfCode; i++) + Text( + i.toString(), + style: textStyle.copyWith( + color: AFThemeExtension.of(context).textColor.withAlpha(155), + ), + ), + ], + ), + ); + } +} + +class _CopyButton extends StatelessWidget { + const _CopyButton({required this.node}); + + final Node node; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: FlowyTooltip( + message: LocaleKeys.document_codeBlock_copyTooltip.tr(), + child: FlowyIconButton( + onPressed: () async { + await getIt().setData( + ClipboardServiceData( + plainText: node.delta?.toPlainText(), + ), + ); + + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), + ); + } + }, + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + icon: FlowySvg( + FlowySvgs.copy_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ); + } +} + +class _LanguageSelector extends StatefulWidget { + const _LanguageSelector({ + required this.controller, + this.language, + required this.isSelected, + required this.onLanguageSelected, + this.onMenuOpen, + this.onMenuClose, + }); + + final PopoverController controller; + final String? language; + final bool isSelected; + final void Function(String) onLanguageSelected; + final VoidCallback? onMenuOpen; + final VoidCallback? onMenuClose; + + @override + State<_LanguageSelector> createState() => _LanguageSelectorState(); +} + +class _LanguageSelectorState extends State<_LanguageSelector> { + @override + Widget build(BuildContext context) { + Widget child = Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0), + child: FlowyTextButton( + widget.language?.capitalize() ?? + LocaleKeys.document_codeBlock_language_auto.tr(), + constraints: const BoxConstraints(minWidth: 50), + fontColor: Theme.of(context).colorScheme.onBackground, + fillColor: widget.isSelected + ? Theme.of(context).colorScheme.secondaryContainer + : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), + onPressed: () async { + if (PlatformExtension.isMobile) { + final language = await context + .push(MobileCodeLanguagePickerScreen.routeName); + if (language != null) { + widget.onLanguageSelected(language); + } + } + }, + ), + ), + ], + ); + + if (PlatformExtension.isDesktopOrWeb) { + child = AppFlowyPopover( + controller: widget.controller, + direction: PopoverDirection.bottomWithLeftAligned, + onOpen: widget.onMenuOpen, + constraints: const BoxConstraints(maxHeight: 300, maxWidth: 200), + onClose: widget.onMenuClose, + popupBuilder: (_) => _LanguageSelectionPopover( + editorState: context.read(), + language: widget.language, + onLanguageSelected: (language) { + widget.onLanguageSelected(language); + widget.controller.close(); + }, + ), + child: child, + ); + } + + return child; + } +} + +class _LanguageSelectionPopover extends StatefulWidget { + const _LanguageSelectionPopover({ + required this.editorState, + required this.language, + required this.onLanguageSelected, + }); + + final EditorState editorState; + final String? language; + final void Function(String) onLanguageSelected; + + @override + State<_LanguageSelectionPopover> createState() => + _LanguageSelectionPopoverState(); +} + +class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> { + final searchController = TextEditingController(); + final focusNode = FocusNode(); + + List supportedLanguages = + codeBlockSupportedLanguages.map((e) => e.capitalize()).toList(); + late int selectedIndex = supportedLanguages.indexOf(widget.language ?? ''); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + // This is a workaround because longer taps might break the + // focus, this might be an issue with the Flutter framework. + (_) => Future.delayed( + const Duration(milliseconds: 100), + () => focusNode.requestFocus(), + ), + ); + } + + @override + void dispose() { + focusNode.dispose(); + searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyTextField( + focusNode: focusNode, + autoFocus: false, + controller: searchController, + hintText: LocaleKeys.document_codeBlock_searchLanguageHint.tr(), + onChanged: (_) => setState(() { + supportedLanguages = codeBlockSupportedLanguages + .where((e) => e.contains(searchController.text.toLowerCase())) + .map((e) => e.capitalize()) + .toList(); + selectedIndex = + codeBlockSupportedLanguages.indexOf(widget.language ?? ''); + }), + ), + const VSpace(8), + Flexible( + child: SelectableItemListMenu( + shrinkWrap: true, + items: supportedLanguages, + selectedIndex: selectedIndex, + onSelected: (index) => + widget.onLanguageSelected(supportedLanguages[index]), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_shortcut_event.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_shortcut_event.dart index 8e036c6c69..47b56bc599 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_shortcut_event.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_shortcut_event.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.dart'; + 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/material.dart'; final List codeBlockCharacterEvents = [ enterInCodeBlock, @@ -77,7 +78,10 @@ final CommandShortcutEvent tabToInsertSpacesInCodeBlockCommand = command: 'tab', getDescription: LocaleKeys.settings_shortcuts_commands_codeBlockAddTwoSpaces.tr, - handler: _tabToInsertSpacesInCodeBlockCommandHandler, + handler: (editorState) => _indentationInCodeBlockCommandHandler( + editorState, + true, + ), ); /// shift+tab to delete two spaces at the line start in code block if needed. @@ -91,7 +95,10 @@ final CommandShortcutEvent tabToDeleteSpacesInCodeBlockCommand = command: 'shift+tab', getDescription: LocaleKeys.settings_shortcuts_commands_codeBlockDeleteTwoSpaces.tr, - handler: _tabToDeleteSpacesInCodeBlockCommandHandler, + handler: (editorState) => _indentationInCodeBlockCommandHandler( + editorState, + false, + ), ); /// CTRL+A to select all content inside a Code Block, if cursor is inside one. @@ -130,11 +137,27 @@ CharacterShortcutEventHandler _enterInCodeBlockCommandHandler = if (node == null || node.type != CodeBlockKeys.type) { return false; } + + final lines = node.delta?.toPlainText().split('\n'); + int spaces = 0; + if (lines?.isNotEmpty == true) { + int index = 0; + for (final line in lines!) { + if (index <= selection.endIndex && + selection.endIndex <= index + line.length) { + final lineSpaces = line.length - line.trimLeft().length; + spaces = lineSpaces; + break; + } + index += line.length + 1; + } + } + final transaction = editorState.transaction ..insertText( node, selection.end.offset, - '\n', + '\n${' ' * spaces}', ); await editorState.apply(transaction); return true; @@ -191,10 +214,12 @@ CommandShortcutEventHandler _insertNewParagraphNextToCodeBlockCommandHandler = return KeyEventResult.handled; }; -CommandShortcutEventHandler _tabToInsertSpacesInCodeBlockCommandHandler = - (editorState) { +KeyEventResult _indentationInCodeBlockCommandHandler( + EditorState editorState, + bool shouldIndent, +) { final selection = editorState.selection; - if (selection == null || !selection.isCollapsed) { + if (selection == null) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath(selection.end.path); @@ -202,70 +227,80 @@ CommandShortcutEventHandler _tabToInsertSpacesInCodeBlockCommandHandler = if (node == null || delta == null || node.type != CodeBlockKeys.type) { return KeyEventResult.ignored; } - const spaces = ' '; - final lines = delta.toPlainText().split('\n'); - var index = 0; - for (final line in lines) { - if (index <= selection.endIndex && - selection.endIndex <= index + line.length) { - final transaction = editorState.transaction - ..insertText( - node, - index, - spaces, // two spaces - ) - ..afterSelection = Selection.collapsed( - Position( - path: selection.end.path, - offset: selection.endIndex + spaces.length, - ), - ); - editorState.apply(transaction); - break; - } - index += line.length + 1; - } - return KeyEventResult.handled; -}; -CommandShortcutEventHandler _tabToDeleteSpacesInCodeBlockCommandHandler = - (editorState) { - final selection = editorState.selection; - if (selection == null || !selection.isCollapsed) { - return KeyEventResult.ignored; - } - final node = editorState.getNodeAtPath(selection.end.path); - final delta = node?.delta; - if (node == null || delta == null || node.type != CodeBlockKeys.type) { - return KeyEventResult.ignored; - } const spaces = ' '; final lines = delta.toPlainText().split('\n'); - var index = 0; + int index = 0; + + // We store indexes to be indented in a list, because we should + // indent it in a reverse order to not mess up the offsets. + final List transactions = []; + for (final line in lines) { - if (index <= selection.endIndex && - selection.endIndex <= index + line.length) { - if (line.startsWith(spaces)) { - final transaction = editorState.transaction - ..deleteText( - node, - index, - spaces.length, // two spaces - ) - ..afterSelection = Selection.collapsed( - Position( - path: selection.end.path, - offset: selection.endIndex - spaces.length, - ), - ); - editorState.apply(transaction); + if (!shouldIndent && line.startsWith(spaces) || shouldIndent) { + bool shouldTransform = false; + if (selection.isCollapsed) { + shouldTransform = index <= selection.endIndex && + selection.endIndex <= index + line.length; + } else { + shouldTransform = index + line.length >= selection.startIndex && + selection.endIndex >= index; + + if (shouldIndent && line.trim().isEmpty) { + shouldTransform = false; + } + } + + if (shouldTransform) { + transactions.add(index); } - break; } + index += line.length + 1; } + + if (transactions.isEmpty) { + return KeyEventResult.ignored; + } + + final transaction = editorState.transaction; + + for (final index in transactions.reversed) { + if (shouldIndent) { + transaction.insertText(node, index, spaces); + } else { + transaction.deleteText(node, index, spaces.length); + } + } + + // In case the selection is made backwards, we store the start + // and end here, we will adjust the order later + final start = !selection.isBackward ? selection.end : selection.start; + final end = !selection.isBackward ? selection.start : selection.end; + + final endOffset = shouldIndent + ? end.offset + (spaces.length * transactions.length) + : end.offset - (spaces.length * transactions.length); + + final endSelection = end.copyWith(offset: endOffset); + + final startOffset = shouldIndent + ? start.offset + spaces.length + : start.offset - spaces.length; + + final startSelection = selection.isCollapsed + ? endSelection + : start.copyWith(offset: startOffset); + + transaction.afterSelection = selection.copyWith( + start: selection.isBackward ? startSelection : endSelection, + end: selection.isBackward ? endSelection : startSelection, + ); + + editorState.apply(transaction); + return KeyEventResult.handled; -}; +} CommandShortcutEventHandler _selectAllInCodeBlockCommandHandler = (editorState) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index ad851ed839..044a96098c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,5 +1,8 @@ import 'dart:math'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; @@ -12,8 +15,6 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -160,7 +161,8 @@ class EditorStyleCustomizer { TextStyle codeBlockStyleBuilder() { final theme = Theme.of(context); final fontSize = context.read().state.fontSize; - final fontFamily = context.read().state.fontFamily; + final fontFamily = + context.read().state.codeFontFamily; return baseTextStyle(fontFamily).copyWith( fontSize: fontSize, height: 1.5, @@ -219,6 +221,13 @@ class EditorStyleCustomizer { fontWeight: fontWeight, ); } on Exception { + if ([builtInFontFamily, builtInCodeFontFamily].contains(fontFamily)) { + return TextStyle( + fontFamily: fontFamily, + fontWeight: fontWeight, + ); + } + return GoogleFonts.getFont(builtInFontFamily); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart index e54289112c..e7006b8a87 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart @@ -1,10 +1,12 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; const builtInFontFamily = 'Poppins'; +const builtInCodeFontFamily = 'RobotoMono'; abstract class BaseAppearance { final white = const Color(0xFFFFFFFF); diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart index 378f29d3a1..d9420944fb 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -1,7 +1,8 @@ -import 'package:appflowy_popover/src/layout.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:appflowy_popover/src/layout.dart'; + import 'mask.dart'; import 'mutex.dart'; @@ -127,8 +128,8 @@ class PopoverState extends State { @override void initState() { - widget.controller?._state = this; super.initState(); + widget.controller?._state = this; } void showOverlay() { @@ -161,22 +162,17 @@ class PopoverState extends State { offset: widget.offset ?? Offset.zero, windowPadding: widget.windowPadding ?? EdgeInsets.zero, popupBuilder: widget.popupBuilder, - onClose: () => close(), - onCloseAll: () => _removeRootOverlay(), + onClose: close, + onCloseAll: _removeRootOverlay, skipTraversal: widget.skipTraversal, ), ); return CallbackShortcuts( bindings: { - const SingleActivator(LogicalKeyboardKey.escape): () => - _removeRootOverlay(), + const SingleActivator(LogicalKeyboardKey.escape): _removeRootOverlay, }, - child: FocusScope( - child: Stack( - children: children, - ), - ), + child: FocusScope(child: Stack(children: children)), ); }); _rootEntry.addEntry(context, this, newEntry, widget.asBarrier); diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index ea3b4b6913..ea998fd585 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -992,8 +992,12 @@ "codeBlock": { "language": { "label": "Language", - "placeholder": "Select language" - } + "placeholder": "Select language", + "auto": "Auto" + }, + "copyTooltip": "Copy contents of the code block", + "searchLanguageHint": "Search for a language", + "codeCopiedSnackbar": "Code copied to clipboard!" }, "inlineLink": { "placeholder": "Paste or type a link", @@ -1452,4 +1456,4 @@ "noNetworkConnected": "No network connected" } } -} \ No newline at end of file +}