feat: code block improvements (#5054)

* feat: prettify code block component + copy code

* feat: search for languages in code block

* feat: non-collapsed selection indentation in code block

* fix: focus workaround for language search

* feat: multi selection outdent

* feat: add line numbering

* feat: prefer built in mono font for code block

* fix: add clamping physics to scrollview

* feat: pseudo auto indent and fix rebuild issue

* refactor: consolidate commands

* fix: track cursor in code block

* fix: no need to set selection on updating language
This commit is contained in:
Mathias Mogensen 2024-04-10 09:33:00 +02:00 committed by GitHub
parent a1a7321f4c
commit d35742d34c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 638 additions and 202 deletions

View File

@ -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<DocumentAppearance> {
const DocumentAppearance(
fontSize: 16.0,
fontFamily: builtInFontFamily,
codeFontFamily: builtInCodeFontFamily,
),
);

View File

@ -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<String, BlockComponentBuilder> getEditorBuilderMap({
required BuildContext context,
@ -151,11 +152,7 @@ Map<String, BlockComponentBuilder> 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(),

View File

@ -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<CommandShortcutEvent> commandShortcutEvents = [
@ -143,10 +145,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
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

View File

@ -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<bool> _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;
}

View File

@ -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<String> 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),
),
);
}
}

View File

@ -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<CodeBlockComponentWidget>
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<State<StatefulWidget>> blockComponentKey = GlobalKey(
debugLabel: CodeBlockKeys.type,
);
GlobalKey<State<StatefulWidget>> blockComponentKey =
GlobalKey(debugLabel: CodeBlockKeys.type);
@override
BlockComponentConfiguration get configuration => widget.configuration;
@ -183,50 +190,108 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
@override
Node get node => widget.node;
final popoverController = PopoverController();
@override
late final editorState = context.read<EditorState>();
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<CodeBlockComponentWidget>
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<String>(
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<void> 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<TextSpan> _convert(
@ -358,7 +437,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
bool isLightMode = true,
}) {
final List<TextSpan> spans = [];
var currentSpans = spans;
List<TextSpan> currentSpans = spans;
final List<List<TextSpan>> stack = [];
final codeblockTheme =
@ -401,3 +480,221 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
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<ClipboardService>().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<String>(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<EditorState>(),
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<String> 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]),
),
),
],
);
}
}

View File

@ -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<CharacterShortcutEvent> 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<int> 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) {

View File

@ -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<DocumentAppearanceCubit>().state.fontSize;
final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
final fontFamily =
context.read<DocumentAppearanceCubit>().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);
}
}

View File

@ -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);

View File

@ -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<Popover> {
@override
void initState() {
widget.controller?._state = this;
super.initState();
widget.controller?._state = this;
}
void showOverlay() {
@ -161,22 +162,17 @@ class PopoverState extends State<Popover> {
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);

View File

@ -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"
}
}
}
}