mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
a1a7321f4c
commit
d35742d34c
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user