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({
|
const DocumentAppearance({
|
||||||
required this.fontSize,
|
required this.fontSize,
|
||||||
required this.fontFamily,
|
required this.fontFamily,
|
||||||
|
required this.codeFontFamily,
|
||||||
this.cursorColor,
|
this.cursorColor,
|
||||||
this.selectionColor,
|
this.selectionColor,
|
||||||
this.defaultTextDirection,
|
this.defaultTextDirection,
|
||||||
@ -19,6 +20,7 @@ class DocumentAppearance {
|
|||||||
|
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
final String fontFamily;
|
final String fontFamily;
|
||||||
|
final String codeFontFamily;
|
||||||
final Color? cursorColor;
|
final Color? cursorColor;
|
||||||
final Color? selectionColor;
|
final Color? selectionColor;
|
||||||
final String? defaultTextDirection;
|
final String? defaultTextDirection;
|
||||||
@ -31,6 +33,7 @@ class DocumentAppearance {
|
|||||||
DocumentAppearance copyWith({
|
DocumentAppearance copyWith({
|
||||||
double? fontSize,
|
double? fontSize,
|
||||||
String? fontFamily,
|
String? fontFamily,
|
||||||
|
String? codeFontFamily,
|
||||||
Color? cursorColor,
|
Color? cursorColor,
|
||||||
Color? selectionColor,
|
Color? selectionColor,
|
||||||
String? defaultTextDirection,
|
String? defaultTextDirection,
|
||||||
@ -41,6 +44,7 @@ class DocumentAppearance {
|
|||||||
return DocumentAppearance(
|
return DocumentAppearance(
|
||||||
fontSize: fontSize ?? this.fontSize,
|
fontSize: fontSize ?? this.fontSize,
|
||||||
fontFamily: fontFamily ?? this.fontFamily,
|
fontFamily: fontFamily ?? this.fontFamily,
|
||||||
|
codeFontFamily: codeFontFamily ?? this.codeFontFamily,
|
||||||
cursorColor: cursorColorIsNull ? null : cursorColor ?? this.cursorColor,
|
cursorColor: cursorColorIsNull ? null : cursorColor ?? this.cursorColor,
|
||||||
selectionColor:
|
selectionColor:
|
||||||
selectionColorIsNull ? null : selectionColor ?? this.selectionColor,
|
selectionColorIsNull ? null : selectionColor ?? this.selectionColor,
|
||||||
@ -57,6 +61,7 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
|
|||||||
const DocumentAppearance(
|
const DocumentAppearance(
|
||||||
fontSize: 16.0,
|
fontSize: 16.0,
|
||||||
fontFamily: builtInFontFamily,
|
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/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/actions/mobile_block_action_buttons.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.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:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||||
import 'package:flowy_infra/theme_extension.dart';
|
import 'package:flowy_infra/theme_extension.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
@ -151,11 +152,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
|||||||
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
|
textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
|
||||||
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
|
placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34),
|
||||||
left: 30,
|
|
||||||
right: 30,
|
|
||||||
bottom: 36,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
|
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
|
||||||
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
|
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/application/document_bloc.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_configuration.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/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/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/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/i18n/editor_i18n.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.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:collection/collection.dart';
|
||||||
import 'package:flowy_infra/theme_extension.dart';
|
import 'package:flowy_infra/theme_extension.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.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';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||||
@ -143,10 +145,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
|||||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
customFormatGreaterEqual,
|
||||||
|
|
||||||
...standardCharacterShortcutEvents
|
...standardCharacterShortcutEvents
|
||||||
..removeWhere(
|
..removeWhere(
|
||||||
(element) => element == slashCommand,
|
(shortcut) => [
|
||||||
), // remove the default slash command.
|
slashCommand, // Remove default slash command
|
||||||
|
formatGreaterEqual, // Overridden by customFormatGreaterEqual
|
||||||
|
].contains(shortcut),
|
||||||
|
),
|
||||||
|
|
||||||
/// Inline Actions
|
/// Inline Actions
|
||||||
/// - Reminder
|
/// - 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:appflowy/generated/flowy_svgs.g.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class SelectableItemListMenu extends StatelessWidget {
|
class SelectableItemListMenu extends StatelessWidget {
|
||||||
const SelectableItemListMenu({
|
const SelectableItemListMenu({
|
||||||
@ -8,24 +9,29 @@ class SelectableItemListMenu extends StatelessWidget {
|
|||||||
required this.items,
|
required this.items,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
|
this.shrinkWrap = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> items;
|
final List<String> items;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final void Function(int) onSelected;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemBuilder: (context, index) {
|
shrinkWrap: shrinkWrap,
|
||||||
final item = items[index];
|
|
||||||
return SelectableItem(
|
|
||||||
isSelected: index == selectedIndex,
|
|
||||||
item: item,
|
|
||||||
onTap: () => onSelected(index),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) => SelectableItem(
|
||||||
|
isSelected: index == selectedIndex,
|
||||||
|
item: items[index],
|
||||||
|
onTap: () => onSelected(index),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,22 @@
|
|||||||
import 'dart:async';
|
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/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/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/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/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/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_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||||
import 'package:flowy_infra/theme_extension.dart';
|
import 'package:flowy_infra/theme_extension.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.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:go_router/go_router.dart';
|
||||||
import 'package:highlight/highlight.dart' as highlight;
|
import 'package:highlight/highlight.dart' as highlight;
|
||||||
import 'package:highlight/languages/all.dart';
|
import 'package:highlight/languages/all.dart';
|
||||||
@ -114,6 +120,8 @@ SelectionMenuItem codeBlockItem = SelectionMenuItem.node(
|
|||||||
replace: (_, node) => node.delta?.isEmpty ?? false,
|
replace: (_, node) => node.delta?.isEmpty ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const _interceptorKey = 'code-block-interceptor';
|
||||||
|
|
||||||
class CodeBlockComponentBuilder extends BlockComponentBuilder {
|
class CodeBlockComponentBuilder extends BlockComponentBuilder {
|
||||||
CodeBlockComponentBuilder({
|
CodeBlockComponentBuilder({
|
||||||
super.configuration,
|
super.configuration,
|
||||||
@ -167,12 +175,11 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
|
|||||||
BlockComponentTextDirectionMixin {
|
BlockComponentTextDirectionMixin {
|
||||||
// the key used to forward focus to the richtext child
|
// the key used to forward focus to the richtext child
|
||||||
@override
|
@override
|
||||||
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
|
final forwardKey = GlobalKey(debugLabel: 'code_flowy_rich_text');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GlobalKey<State<StatefulWidget>> blockComponentKey = GlobalKey(
|
GlobalKey<State<StatefulWidget>> blockComponentKey =
|
||||||
debugLabel: CodeBlockKeys.type,
|
GlobalKey(debugLabel: CodeBlockKeys.type);
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
BlockComponentConfiguration get configuration => widget.configuration;
|
BlockComponentConfiguration get configuration => widget.configuration;
|
||||||
@ -183,50 +190,108 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
|
|||||||
@override
|
@override
|
||||||
Node get node => widget.node;
|
Node get node => widget.node;
|
||||||
|
|
||||||
final popoverController = PopoverController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
late final editorState = context.read<EditorState>();
|
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? get language => node.attributes[CodeBlockKeys.language] as String?;
|
||||||
String? autoDetectLanguage;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textDirection = calculateTextDirection(
|
final textDirection = calculateTextDirection(
|
||||||
layoutDirection: Directionality.maybeOf(context),
|
layoutDirection: Directionality.maybeOf(context),
|
||||||
);
|
);
|
||||||
Widget child = Container(
|
|
||||||
decoration: BoxDecoration(
|
Widget child = MouseRegion(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
onEnter: (_) => setState(() => isHovering = true),
|
||||||
color: AFThemeExtension.of(context).calloutBGColor,
|
onExit: (_) => setState(() => isHovering = false),
|
||||||
),
|
child: DecoratedBox(
|
||||||
width: MediaQuery.of(context).size.width,
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: AFThemeExtension.of(context).calloutBGColor,
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
textDirection: textDirection,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_buildSwitchLanguageButton(context),
|
mainAxisSize: MainAxisSize.min,
|
||||||
_buildCodeBlock(context, textDirection),
|
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(
|
child = Padding(key: blockComponentKey, padding: padding, child: child);
|
||||||
key: blockComponentKey,
|
|
||||||
padding: padding,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
|
|
||||||
child = BlockSelectionContainer(
|
child = BlockSelectionContainer(
|
||||||
node: node,
|
node: node,
|
||||||
delegate: this,
|
delegate: this,
|
||||||
listenable: editorState.selectionNotifier,
|
listenable: editorState.selectionNotifier,
|
||||||
blockColor: editorState.editorStyle.selectionColor,
|
blockColor: editorState.editorStyle.selectionColor,
|
||||||
supportTypes: const [
|
supportTypes: const [BlockSelectionType.block],
|
||||||
BlockSelectionType.block,
|
|
||||||
],
|
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -260,97 +325,111 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
|
|||||||
language: language,
|
language: language,
|
||||||
autoDetection: language == null,
|
autoDetection: language == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
autoDetectLanguage = language ?? result.language;
|
autoDetectLanguage = language ?? result.language;
|
||||||
|
|
||||||
final codeNodes = result.nodes;
|
final codeNodes = result.nodes;
|
||||||
if (codeNodes == null) {
|
if (codeNodes == null) {
|
||||||
throw Exception('Code block parse error.');
|
throw Exception('Code block parse error.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final codeTextSpans = _convert(codeNodes, isLightMode: isLightMode);
|
final codeTextSpans = _convert(codeNodes, isLightMode: isLightMode);
|
||||||
|
final linesOfCode = delta.toPlainText().split('\n').length;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: widget.padding,
|
padding: widget.padding,
|
||||||
child: AppFlowyRichText(
|
child: Row(
|
||||||
key: forwardKey,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
delegate: this,
|
children: [
|
||||||
node: widget.node,
|
_LinesOfCodeNumbers(
|
||||||
editorState: editorState,
|
linesOfCode: linesOfCode,
|
||||||
placeholderText: placeholderText,
|
textStyle: textStyle,
|
||||||
lineHeight: 1.5,
|
),
|
||||||
textSpanDecorator: (textSpan) => TextSpan(
|
Flexible(
|
||||||
style: textStyle,
|
child: SingleChildScrollView(
|
||||||
children: codeTextSpans,
|
key: codeBlockKey,
|
||||||
),
|
controller: scrollController,
|
||||||
placeholderTextSpanDecorator: (textSpan) => TextSpan(
|
physics: const ClampingScrollPhysics(),
|
||||||
style: textStyle,
|
scrollDirection: Axis.horizontal,
|
||||||
),
|
child: AppFlowyRichText(
|
||||||
textDirection: textDirection,
|
key: forwardKey,
|
||||||
cursorColor: editorState.editorStyle.cursorColor,
|
delegate: this,
|
||||||
selectionColor: editorState.editorStyle.selectionColor,
|
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 {
|
Future<void> updateLanguage(String language) async {
|
||||||
final transaction = editorState.transaction
|
final transaction = editorState.transaction
|
||||||
..updateNode(node, {
|
..updateNode(
|
||||||
CodeBlockKeys.language: language == 'auto' ? null : language,
|
node,
|
||||||
})
|
{CodeBlockKeys.language: language == 'auto' ? null : language},
|
||||||
..afterSelection = Selection.collapsed(
|
|
||||||
Position(path: node.path, offset: node.delta?.length ?? 0),
|
|
||||||
);
|
);
|
||||||
await editorState.apply(transaction);
|
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.
|
// Copy from flutter.highlight package.
|
||||||
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
|
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
|
||||||
List<TextSpan> _convert(
|
List<TextSpan> _convert(
|
||||||
@ -358,7 +437,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
|
|||||||
bool isLightMode = true,
|
bool isLightMode = true,
|
||||||
}) {
|
}) {
|
||||||
final List<TextSpan> spans = [];
|
final List<TextSpan> spans = [];
|
||||||
var currentSpans = spans;
|
List<TextSpan> currentSpans = spans;
|
||||||
final List<List<TextSpan>> stack = [];
|
final List<List<TextSpan>> stack = [];
|
||||||
|
|
||||||
final codeblockTheme =
|
final codeblockTheme =
|
||||||
@ -401,3 +480,221 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
|
|||||||
return spans;
|
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/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
final List<CharacterShortcutEvent> codeBlockCharacterEvents = [
|
final List<CharacterShortcutEvent> codeBlockCharacterEvents = [
|
||||||
enterInCodeBlock,
|
enterInCodeBlock,
|
||||||
@ -77,7 +78,10 @@ final CommandShortcutEvent tabToInsertSpacesInCodeBlockCommand =
|
|||||||
command: 'tab',
|
command: 'tab',
|
||||||
getDescription:
|
getDescription:
|
||||||
LocaleKeys.settings_shortcuts_commands_codeBlockAddTwoSpaces.tr,
|
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.
|
/// 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',
|
command: 'shift+tab',
|
||||||
getDescription:
|
getDescription:
|
||||||
LocaleKeys.settings_shortcuts_commands_codeBlockDeleteTwoSpaces.tr,
|
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.
|
/// 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) {
|
if (node == null || node.type != CodeBlockKeys.type) {
|
||||||
return false;
|
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
|
final transaction = editorState.transaction
|
||||||
..insertText(
|
..insertText(
|
||||||
node,
|
node,
|
||||||
selection.end.offset,
|
selection.end.offset,
|
||||||
'\n',
|
'\n${' ' * spaces}',
|
||||||
);
|
);
|
||||||
await editorState.apply(transaction);
|
await editorState.apply(transaction);
|
||||||
return true;
|
return true;
|
||||||
@ -191,10 +214,12 @@ CommandShortcutEventHandler _insertNewParagraphNextToCodeBlockCommandHandler =
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
};
|
};
|
||||||
|
|
||||||
CommandShortcutEventHandler _tabToInsertSpacesInCodeBlockCommandHandler =
|
KeyEventResult _indentationInCodeBlockCommandHandler(
|
||||||
(editorState) {
|
EditorState editorState,
|
||||||
|
bool shouldIndent,
|
||||||
|
) {
|
||||||
final selection = editorState.selection;
|
final selection = editorState.selection;
|
||||||
if (selection == null || !selection.isCollapsed) {
|
if (selection == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
final node = editorState.getNodeAtPath(selection.end.path);
|
final node = editorState.getNodeAtPath(selection.end.path);
|
||||||
@ -202,70 +227,80 @@ CommandShortcutEventHandler _tabToInsertSpacesInCodeBlockCommandHandler =
|
|||||||
if (node == null || delta == null || node.type != CodeBlockKeys.type) {
|
if (node == null || delta == null || node.type != CodeBlockKeys.type) {
|
||||||
return KeyEventResult.ignored;
|
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 = ' ';
|
const spaces = ' ';
|
||||||
final lines = delta.toPlainText().split('\n');
|
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) {
|
for (final line in lines) {
|
||||||
if (index <= selection.endIndex &&
|
if (!shouldIndent && line.startsWith(spaces) || shouldIndent) {
|
||||||
selection.endIndex <= index + line.length) {
|
bool shouldTransform = false;
|
||||||
if (line.startsWith(spaces)) {
|
if (selection.isCollapsed) {
|
||||||
final transaction = editorState.transaction
|
shouldTransform = index <= selection.endIndex &&
|
||||||
..deleteText(
|
selection.endIndex <= index + line.length;
|
||||||
node,
|
} else {
|
||||||
index,
|
shouldTransform = index + line.length >= selection.startIndex &&
|
||||||
spaces.length, // two spaces
|
selection.endIndex >= index;
|
||||||
)
|
|
||||||
..afterSelection = Selection.collapsed(
|
if (shouldIndent && line.trim().isEmpty) {
|
||||||
Position(
|
shouldTransform = false;
|
||||||
path: selection.end.path,
|
}
|
||||||
offset: selection.endIndex - spaces.length,
|
}
|
||||||
),
|
|
||||||
);
|
if (shouldTransform) {
|
||||||
editorState.apply(transaction);
|
transactions.add(index);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
index += line.length + 1;
|
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;
|
return KeyEventResult.handled;
|
||||||
};
|
}
|
||||||
|
|
||||||
CommandShortcutEventHandler _selectAllInCodeBlockCommandHandler =
|
CommandShortcutEventHandler _selectAllInCodeBlockCommandHandler =
|
||||||
(editorState) {
|
(editorState) {
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||||
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.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/workspace/application/settings/appearance/base_appearance.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
@ -160,7 +161,8 @@ class EditorStyleCustomizer {
|
|||||||
TextStyle codeBlockStyleBuilder() {
|
TextStyle codeBlockStyleBuilder() {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
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(
|
return baseTextStyle(fontFamily).copyWith(
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
@ -219,6 +221,13 @@ class EditorStyleCustomizer {
|
|||||||
fontWeight: fontWeight,
|
fontWeight: fontWeight,
|
||||||
);
|
);
|
||||||
} on Exception {
|
} on Exception {
|
||||||
|
if ([builtInFontFamily, builtInCodeFontFamily].contains(fontFamily)) {
|
||||||
|
return TextStyle(
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return GoogleFonts.getFont(builtInFontFamily);
|
return GoogleFonts.getFont(builtInFontFamily);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:flowy_infra/size.dart';
|
import 'package:flowy_infra/size.dart';
|
||||||
import 'package:flowy_infra/theme.dart';
|
import 'package:flowy_infra/theme.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
const builtInFontFamily = 'Poppins';
|
const builtInFontFamily = 'Poppins';
|
||||||
|
const builtInCodeFontFamily = 'RobotoMono';
|
||||||
|
|
||||||
abstract class BaseAppearance {
|
abstract class BaseAppearance {
|
||||||
final white = const Color(0xFFFFFFFF);
|
final white = const Color(0xFFFFFFFF);
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import 'package:appflowy_popover/src/layout.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:appflowy_popover/src/layout.dart';
|
||||||
|
|
||||||
import 'mask.dart';
|
import 'mask.dart';
|
||||||
import 'mutex.dart';
|
import 'mutex.dart';
|
||||||
|
|
||||||
@ -127,8 +128,8 @@ class PopoverState extends State<Popover> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
widget.controller?._state = this;
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
widget.controller?._state = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
void showOverlay() {
|
void showOverlay() {
|
||||||
@ -161,22 +162,17 @@ class PopoverState extends State<Popover> {
|
|||||||
offset: widget.offset ?? Offset.zero,
|
offset: widget.offset ?? Offset.zero,
|
||||||
windowPadding: widget.windowPadding ?? EdgeInsets.zero,
|
windowPadding: widget.windowPadding ?? EdgeInsets.zero,
|
||||||
popupBuilder: widget.popupBuilder,
|
popupBuilder: widget.popupBuilder,
|
||||||
onClose: () => close(),
|
onClose: close,
|
||||||
onCloseAll: () => _removeRootOverlay(),
|
onCloseAll: _removeRootOverlay,
|
||||||
skipTraversal: widget.skipTraversal,
|
skipTraversal: widget.skipTraversal,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return CallbackShortcuts(
|
return CallbackShortcuts(
|
||||||
bindings: {
|
bindings: {
|
||||||
const SingleActivator(LogicalKeyboardKey.escape): () =>
|
const SingleActivator(LogicalKeyboardKey.escape): _removeRootOverlay,
|
||||||
_removeRootOverlay(),
|
|
||||||
},
|
},
|
||||||
child: FocusScope(
|
child: FocusScope(child: Stack(children: children)),
|
||||||
child: Stack(
|
|
||||||
children: children,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
_rootEntry.addEntry(context, this, newEntry, widget.asBarrier);
|
_rootEntry.addEntry(context, this, newEntry, widget.asBarrier);
|
||||||
|
@ -992,8 +992,12 @@
|
|||||||
"codeBlock": {
|
"codeBlock": {
|
||||||
"language": {
|
"language": {
|
||||||
"label": "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": {
|
"inlineLink": {
|
||||||
"placeholder": "Paste or type a link",
|
"placeholder": "Paste or type a link",
|
||||||
@ -1452,4 +1456,4 @@
|
|||||||
"noNetworkConnected": "No network connected"
|
"noNetworkConnected": "No network connected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user