mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: implement simple code block
This commit is contained in:
parent
2d32e02dba
commit
ff9cf905fa
@ -25,6 +25,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"attributes": { "subtype": "code_block", "theme": "vs", "language": "dart" },
|
||||||
|
"delta": [{ "insert": "final x = 0;\nprint($x);" }]
|
||||||
|
},
|
||||||
{ "type": "text", "delta": [] },
|
{ "type": "text", "delta": [] },
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:example/plugin/code_block_node_widget.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -116,9 +117,17 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
editorState: _editorState!,
|
editorState: _editorState!,
|
||||||
editorStyle: _editorStyle,
|
editorStyle: _editorStyle,
|
||||||
editable: true,
|
editable: true,
|
||||||
|
customBuilders: {
|
||||||
|
'text/code_block': CodeBlockNodeWidgetBuilder(),
|
||||||
|
},
|
||||||
shortcutEvents: [
|
shortcutEvents: [
|
||||||
|
enterInCodeBlock,
|
||||||
|
ignoreKeysInCodeBlock,
|
||||||
underscoreToItalic,
|
underscoreToItalic,
|
||||||
],
|
],
|
||||||
|
selectionMenuItems: [
|
||||||
|
codeBlockItem,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -0,0 +1,275 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:highlight/highlight.dart' as highlight;
|
||||||
|
import 'package:highlight/languages/all.dart';
|
||||||
|
|
||||||
|
ShortcutEvent enterInCodeBlock = ShortcutEvent(
|
||||||
|
key: 'Enter in code block',
|
||||||
|
command: 'enter',
|
||||||
|
handler: _enterInCodeBlockHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
|
||||||
|
key: 'White space in code block',
|
||||||
|
command: 'space,slash,shift+underscore',
|
||||||
|
handler: _ignorekHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
|
||||||
|
final selection = editorState.service.selectionService.currentSelection.value;
|
||||||
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
|
final codeBlockNode =
|
||||||
|
nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
|
||||||
|
if (codeBlockNode.length != 1 || selection == null) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
if (selection.isCollapsed) {
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..insertText(codeBlockNode.first, selection.end.offset, '\n')
|
||||||
|
..commit();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
};
|
||||||
|
|
||||||
|
ShortcutEventHandler _ignorekHandler = (editorState, event) {
|
||||||
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
|
final codeBlockNodes =
|
||||||
|
nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
|
||||||
|
if (codeBlockNodes.length == 1) {
|
||||||
|
return KeyEventResult.skipRemainingHandlers;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
};
|
||||||
|
|
||||||
|
SelectionMenuItem codeBlockItem = SelectionMenuItem(
|
||||||
|
name: 'Code Block',
|
||||||
|
icon: const Icon(Icons.abc),
|
||||||
|
keywords: ['code block'],
|
||||||
|
handler: (editorState, _, __) {
|
||||||
|
final selection =
|
||||||
|
editorState.service.selectionService.currentSelection.value;
|
||||||
|
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||||
|
.whereType<TextNode>();
|
||||||
|
if (selection == null || textNodes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (textNodes.first.toRawString().isEmpty) {
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..updateNode(textNodes.first, {
|
||||||
|
'subtype': 'code_block',
|
||||||
|
'theme': 'vs',
|
||||||
|
'language': null,
|
||||||
|
})
|
||||||
|
..afterSelection = selection
|
||||||
|
..commit();
|
||||||
|
} else {
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..insertNode(
|
||||||
|
selection.end.path.next,
|
||||||
|
TextNode(
|
||||||
|
type: 'text',
|
||||||
|
children: LinkedList(),
|
||||||
|
attributes: {
|
||||||
|
'subtype': 'code_block',
|
||||||
|
'theme': 'vs',
|
||||||
|
'language': null,
|
||||||
|
},
|
||||||
|
delta: Delta()..insert('\n'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..afterSelection = selection
|
||||||
|
..commit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
|
@override
|
||||||
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
|
return _CodeBlockNodeWidge(
|
||||||
|
key: context.node.key,
|
||||||
|
textNode: context.node,
|
||||||
|
editorState: context.editorState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => (node) {
|
||||||
|
return node is TextNode && node.attributes['theme'] is String;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CodeBlockNodeWidge extends StatefulWidget {
|
||||||
|
const _CodeBlockNodeWidge({
|
||||||
|
Key? key,
|
||||||
|
required this.textNode,
|
||||||
|
required this.editorState,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final TextNode textNode;
|
||||||
|
final EditorState editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
|
||||||
|
with SelectableMixin, DefaultSelectable {
|
||||||
|
final _richTextKey = GlobalKey(debugLabel: 'code_block_text');
|
||||||
|
final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20);
|
||||||
|
String? get _language => widget.textNode.attributes['language'] as String?;
|
||||||
|
|
||||||
|
@override
|
||||||
|
SelectableMixin<StatefulWidget> get forward =>
|
||||||
|
_richTextKey.currentState as SelectableMixin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
GlobalKey<State<StatefulWidget>>? get iconKey => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset get baseOffset => super.baseOffset + _padding.topLeft;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
_buildCodeBlock(context),
|
||||||
|
_buildSwitchCodeButton(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCodeBlock(BuildContext context) {
|
||||||
|
final result = highlight.highlight.parse(
|
||||||
|
widget.textNode.toRawString(),
|
||||||
|
language: _language,
|
||||||
|
autoDetection: _language == null,
|
||||||
|
);
|
||||||
|
final code = result.nodes;
|
||||||
|
final codeTextSpan = _convert(code!);
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
padding: _padding,
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: FlowyRichText(
|
||||||
|
key: _richTextKey,
|
||||||
|
textNode: widget.textNode,
|
||||||
|
editorState: widget.editorState,
|
||||||
|
textSpanDecorator: (textSpan) => TextSpan(
|
||||||
|
style: widget.editorState.editorStyle.textStyle.defaultTextStyle,
|
||||||
|
children: codeTextSpan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSwitchCodeButton(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
top: -5,
|
||||||
|
right: 0,
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _language,
|
||||||
|
onChanged: (value) {
|
||||||
|
TransactionBuilder(widget.editorState)
|
||||||
|
..updateNode(widget.textNode, {
|
||||||
|
'language': value,
|
||||||
|
})
|
||||||
|
..commit();
|
||||||
|
},
|
||||||
|
items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
|
||||||
|
return DropdownMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontSize: 12.0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(growable: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy from flutter.highlight package.
|
||||||
|
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
|
||||||
|
List<TextSpan> _convert(List<highlight.Node> nodes) {
|
||||||
|
List<TextSpan> spans = [];
|
||||||
|
var currentSpans = spans;
|
||||||
|
List<List<TextSpan>> stack = [];
|
||||||
|
|
||||||
|
_traverse(highlight.Node node) {
|
||||||
|
if (node.value != null) {
|
||||||
|
currentSpans.add(node.className == null
|
||||||
|
? TextSpan(text: node.value)
|
||||||
|
: TextSpan(
|
||||||
|
text: node.value,
|
||||||
|
style: _builtInCodeBlockTheme[node.className!]));
|
||||||
|
} else if (node.children != null) {
|
||||||
|
List<TextSpan> tmp = [];
|
||||||
|
currentSpans.add(TextSpan(
|
||||||
|
children: tmp, style: _builtInCodeBlockTheme[node.className!]));
|
||||||
|
stack.add(currentSpans);
|
||||||
|
currentSpans = tmp;
|
||||||
|
|
||||||
|
for (var n in node.children!) {
|
||||||
|
_traverse(n);
|
||||||
|
if (n == node.children!.last) {
|
||||||
|
currentSpans = stack.isEmpty ? spans : stack.removeLast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var node in nodes) {
|
||||||
|
_traverse(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _builtInCodeBlockTheme = {
|
||||||
|
'root':
|
||||||
|
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
|
||||||
|
'comment': TextStyle(color: Color(0xff007400)),
|
||||||
|
'quote': TextStyle(color: Color(0xff007400)),
|
||||||
|
'tag': TextStyle(color: Color(0xffaa0d91)),
|
||||||
|
'attribute': TextStyle(color: Color(0xffaa0d91)),
|
||||||
|
'keyword': TextStyle(color: Color(0xffaa0d91)),
|
||||||
|
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
|
||||||
|
'literal': TextStyle(color: Color(0xffaa0d91)),
|
||||||
|
'name': TextStyle(color: Color(0xffaa0d91)),
|
||||||
|
'variable': TextStyle(color: Color(0xff3F6E74)),
|
||||||
|
'template-variable': TextStyle(color: Color(0xff3F6E74)),
|
||||||
|
'code': TextStyle(color: Color(0xffc41a16)),
|
||||||
|
'string': TextStyle(color: Color(0xffc41a16)),
|
||||||
|
'meta-string': TextStyle(color: Color(0xffc41a16)),
|
||||||
|
'regexp': TextStyle(color: Color(0xff0E0EFF)),
|
||||||
|
'link': TextStyle(color: Color(0xff0E0EFF)),
|
||||||
|
'title': TextStyle(color: Color(0xff1c00cf)),
|
||||||
|
'symbol': TextStyle(color: Color(0xff1c00cf)),
|
||||||
|
'bullet': TextStyle(color: Color(0xff1c00cf)),
|
||||||
|
'number': TextStyle(color: Color(0xff1c00cf)),
|
||||||
|
'section': TextStyle(color: Color(0xff643820)),
|
||||||
|
'meta': TextStyle(color: Color(0xff643820)),
|
||||||
|
'type': TextStyle(color: Color(0xff5c2699)),
|
||||||
|
'built_in': TextStyle(color: Color(0xff5c2699)),
|
||||||
|
'builtin-name': TextStyle(color: Color(0xff5c2699)),
|
||||||
|
'params': TextStyle(color: Color(0xff5c2699)),
|
||||||
|
'attr': TextStyle(color: Color(0xff836C28)),
|
||||||
|
'subst': TextStyle(color: Color(0xff000000)),
|
||||||
|
'formula': TextStyle(
|
||||||
|
backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic),
|
||||||
|
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
|
||||||
|
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
|
||||||
|
'selector-id': TextStyle(color: Color(0xff9b703f)),
|
||||||
|
'selector-class': TextStyle(color: Color(0xff9b703f)),
|
||||||
|
'doctag': TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
'strong': TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
'emphasis': TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
};
|
@ -43,6 +43,8 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
file_picker: ^5.0.1
|
file_picker: ^5.0.1
|
||||||
universal_html: ^2.0.8
|
universal_html: ^2.0.8
|
||||||
|
highlight: ^0.7.0
|
||||||
|
flutter_highlight: ^0.7.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
@ -28,4 +28,8 @@ export 'src/service/shortcut_event/keybinding.dart';
|
|||||||
export 'src/service/shortcut_event/shortcut_event.dart';
|
export 'src/service/shortcut_event/shortcut_event.dart';
|
||||||
export 'src/service/shortcut_event/shortcut_event_handler.dart';
|
export 'src/service/shortcut_event/shortcut_event_handler.dart';
|
||||||
export 'src/extensions/attributes_extension.dart';
|
export 'src/extensions/attributes_extension.dart';
|
||||||
|
export 'src/extensions/path_extensions.dart';
|
||||||
|
export 'src/render/rich_text/default_selectable.dart';
|
||||||
|
export 'src/render/rich_text/flowy_rich_text.dart';
|
||||||
|
export 'src/render/selection_menu/selection_menu_widget.dart';
|
||||||
export 'src/l10n/l10n.dart';
|
export 'src/l10n/l10n.dart';
|
||||||
|
@ -118,8 +118,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||||||
key: editorState.service.keyboardServiceKey,
|
key: editorState.service.keyboardServiceKey,
|
||||||
editable: widget.editable,
|
editable: widget.editable,
|
||||||
shortcutEvents: [
|
shortcutEvents: [
|
||||||
...builtInShortcutEvents,
|
|
||||||
...widget.shortcutEvents,
|
...widget.shortcutEvents,
|
||||||
|
...builtInShortcutEvents,
|
||||||
],
|
],
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
child: FlowyToolbar(
|
child: FlowyToolbar(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user