mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: improve copy paste plugins, and support in-app copy-paste (#3233)
This commit is contained in:
@ -50,6 +50,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
toggleToggleListCommand,
|
||||
...codeBlockCommands,
|
||||
customCopyCommand,
|
||||
customPasteCommand,
|
||||
...standardCommandShortcutEvents,
|
||||
];
|
||||
|
||||
|
@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:super_clipboard/super_clipboard.dart';
|
||||
|
||||
/// Used for in-app copy and paste without losing the format.
|
||||
///
|
||||
/// It's a Json string representing the copied editor nodes.
|
||||
final inAppJsonFormat = CustomValueFormat<String>(
|
||||
applicationId: 'io.appflowy.InAppJsonType',
|
||||
onDecode: (value, platformType) async {
|
||||
if (value is PlatformDataProvider) {
|
||||
final data = await value.getData(platformType);
|
||||
if (data is List<int>) {
|
||||
return utf8.decode(data, allowMalformed: true);
|
||||
}
|
||||
if (data is String) {
|
||||
return Uri.decodeFull(data);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
class ClipboardServiceData {
|
||||
const ClipboardServiceData({
|
||||
this.plainText,
|
||||
this.html,
|
||||
this.image,
|
||||
this.inAppJson,
|
||||
});
|
||||
|
||||
final String? plainText;
|
||||
final String? html;
|
||||
final (String, Uint8List?)? image;
|
||||
final String? inAppJson;
|
||||
}
|
||||
|
||||
class ClipboardService {
|
||||
Future<void> setData(ClipboardServiceData data) async {
|
||||
final plainText = data.plainText;
|
||||
final html = data.html;
|
||||
final inAppJson = data.inAppJson;
|
||||
final image = data.image;
|
||||
|
||||
final item = DataWriterItem();
|
||||
if (plainText != null) {
|
||||
item.add(Formats.plainText(plainText));
|
||||
}
|
||||
if (html != null) {
|
||||
item.add(Formats.htmlText(html));
|
||||
}
|
||||
if (inAppJson != null) {
|
||||
item.add(inAppJsonFormat(inAppJson));
|
||||
}
|
||||
if (image != null && image.$2?.isNotEmpty == true) {
|
||||
switch (image.$1) {
|
||||
case 'png':
|
||||
item.add(Formats.png(image.$2!));
|
||||
break;
|
||||
case 'jpeg':
|
||||
item.add(Formats.jpeg(image.$2!));
|
||||
break;
|
||||
case 'gif':
|
||||
item.add(Formats.gif(image.$2!));
|
||||
break;
|
||||
default:
|
||||
throw Exception('unsupported image format: ${image.$1}');
|
||||
}
|
||||
}
|
||||
await ClipboardWriter.instance.write([item]);
|
||||
}
|
||||
|
||||
Future<ClipboardServiceData> getData() async {
|
||||
final reader = await ClipboardReader.readClipboard();
|
||||
|
||||
for (final item in reader.items) {
|
||||
final availableFormats = await item.rawReader!.getAvailableFormats();
|
||||
Log.debug(
|
||||
'availableFormats: $availableFormats',
|
||||
);
|
||||
}
|
||||
|
||||
final plainText = await reader.readValue(Formats.plainText);
|
||||
final html = await reader.readValue(Formats.htmlText);
|
||||
final inAppJson = await reader.readValue(inAppJsonFormat);
|
||||
(String, Uint8List?)? image;
|
||||
if (reader.canProvide(Formats.png)) {
|
||||
image = ('png', await reader.readFile(Formats.png));
|
||||
} else if (reader.canProvide(Formats.jpeg)) {
|
||||
image = ('jpeg', await reader.readFile(Formats.jpeg));
|
||||
} else if (reader.canProvide(Formats.gif)) {
|
||||
image = ('gif', await reader.readFile(Formats.gif));
|
||||
}
|
||||
|
||||
return ClipboardServiceData(
|
||||
plainText: plainText,
|
||||
html: html,
|
||||
image: image,
|
||||
inAppJson: inAppJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on DataReader {
|
||||
Future<Uint8List?>? readFile(FileFormat format) {
|
||||
final c = Completer<Uint8List?>();
|
||||
final progress = getFile(
|
||||
format,
|
||||
(file) async {
|
||||
try {
|
||||
final all = await file.readAll();
|
||||
c.complete(all);
|
||||
} catch (e) {
|
||||
c.completeError(e);
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
c.completeError(e);
|
||||
},
|
||||
);
|
||||
if (progress == null) {
|
||||
c.complete(null);
|
||||
}
|
||||
return c.future;
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Copy.
|
||||
///
|
||||
/// - support
|
||||
/// - desktop
|
||||
/// - web
|
||||
/// - mobile
|
||||
///
|
||||
final CommandShortcutEvent customCopyCommand = CommandShortcutEvent(
|
||||
key: 'copy the selected content',
|
||||
command: 'ctrl+c',
|
||||
macOSCommand: 'cmd+c',
|
||||
handler: _copyCommandHandler,
|
||||
);
|
||||
|
||||
CommandShortcutEventHandler _copyCommandHandler = (editorState) {
|
||||
final selection = editorState.selection?.normalized;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// plain text.
|
||||
final text = editorState.getTextInSelection(selection).join('\n');
|
||||
|
||||
final nodes = editorState.getSelectedNodes(selection);
|
||||
final document = Document.blank()..insert([0], nodes);
|
||||
|
||||
// in app json
|
||||
final inAppJson = jsonEncode(document.toJson());
|
||||
|
||||
// html
|
||||
final html = documentToHTML(document);
|
||||
|
||||
() async {
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: text,
|
||||
html: html,
|
||||
inAppJson: inAppJson,
|
||||
image: null,
|
||||
),
|
||||
);
|
||||
}();
|
||||
|
||||
return KeyEventResult.handled;
|
||||
};
|
@ -0,0 +1,62 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Paste.
|
||||
///
|
||||
/// - support
|
||||
/// - desktop
|
||||
/// - web
|
||||
/// - mobile
|
||||
///
|
||||
final CommandShortcutEvent customPasteCommand = CommandShortcutEvent(
|
||||
key: 'paste the content',
|
||||
command: 'ctrl+v',
|
||||
macOSCommand: 'cmd+v',
|
||||
handler: _pasteCommandHandler,
|
||||
);
|
||||
|
||||
CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// because the event handler is not async, so we need to use wrap the async function here
|
||||
() async {
|
||||
// dispatch the paste event
|
||||
final data = await getIt<ClipboardService>().getData();
|
||||
final inAppJson = data.inAppJson;
|
||||
final html = data.html;
|
||||
final plainText = data.plainText;
|
||||
final image = data.image;
|
||||
|
||||
// Order:
|
||||
// 1. in app json format
|
||||
// 2. html
|
||||
// 3. image
|
||||
// 4. plain text
|
||||
|
||||
if (inAppJson != null && inAppJson.isNotEmpty) {
|
||||
await editorState.deleteSelectionIfNeeded();
|
||||
await editorState.pasteInAppJson(inAppJson);
|
||||
} else if (html != null && html.isNotEmpty) {
|
||||
await editorState.deleteSelectionIfNeeded();
|
||||
await editorState.pasteHtml(html);
|
||||
} else if (image != null && image.$2?.isNotEmpty == true) {
|
||||
await editorState.deleteSelectionIfNeeded();
|
||||
await editorState.pasteImage(image.$1, image.$2!);
|
||||
} else if (plainText != null && plainText.isNotEmpty) {
|
||||
await editorState.deleteSelectionIfNeeded();
|
||||
await editorState.pastePlainText(plainText);
|
||||
}
|
||||
}();
|
||||
|
||||
return KeyEventResult.handled;
|
||||
};
|
@ -0,0 +1,155 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension PasteNodes on EditorState {
|
||||
Future<void> pasteSingleLineNode(Node insertedNode) async {
|
||||
final selection = await deleteSelectionIfNeeded();
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
final node = getNodeAtPath(selection.start.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
final transaction = this.transaction;
|
||||
final insertedDelta = insertedNode.delta;
|
||||
// if the node is empty, replace it with the inserted node.
|
||||
if (delta.isEmpty || insertedDelta == null) {
|
||||
transaction.insertNode(
|
||||
selection.end.path.next,
|
||||
node.copyWith(
|
||||
type: node.type,
|
||||
attributes: {
|
||||
...node.attributes,
|
||||
...insertedNode.attributes,
|
||||
},
|
||||
),
|
||||
);
|
||||
transaction.deleteNode(node);
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
path: selection.end.path,
|
||||
offset: insertedDelta?.length ?? 0,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// if the node is not empty, insert the delta from inserted node after the selection.
|
||||
transaction.insertTextDelta(node, selection.endIndex, insertedDelta);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> pasteMultiLineNodes(List<Node> nodes) async {
|
||||
assert(nodes.length > 1);
|
||||
|
||||
final selection = await deleteSelectionIfNeeded();
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
final node = getNodeAtPath(selection.start.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
final transaction = this.transaction;
|
||||
|
||||
final lastNodeLength = nodes.last.delta?.length ?? 0;
|
||||
// merge the current selected node delta into the nodes.
|
||||
if (delta.isNotEmpty) {
|
||||
nodes.first.insertDelta(
|
||||
delta.slice(0, selection.startIndex),
|
||||
insertAfter: false,
|
||||
);
|
||||
|
||||
nodes.last.insertDelta(
|
||||
delta.slice(selection.endIndex),
|
||||
insertAfter: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (delta.isEmpty && node.type != ParagraphBlockKeys.type) {
|
||||
nodes[0] = nodes.first.copyWith(
|
||||
type: node.type,
|
||||
attributes: {
|
||||
...node.attributes,
|
||||
...nodes.first.attributes,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (final child in node.children) {
|
||||
nodes.last.insert(child);
|
||||
}
|
||||
|
||||
transaction.insertNodes(selection.end.path, nodes);
|
||||
|
||||
// delete the current node.
|
||||
transaction.deleteNode(node);
|
||||
|
||||
var path = selection.end.path;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
path = path.next;
|
||||
}
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
path: path.previous, // because a node is deleted.
|
||||
offset: lastNodeLength,
|
||||
),
|
||||
);
|
||||
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
// delete the selection if it's not collapsed.
|
||||
Future<Selection?> deleteSelectionIfNeeded() async {
|
||||
final selection = this.selection;
|
||||
if (selection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// delete the selection first.
|
||||
if (!selection.isCollapsed) {
|
||||
deleteSelection(selection);
|
||||
}
|
||||
|
||||
// fetch selection again.selection = editorState.selection;
|
||||
assert(this.selection?.isCollapsed == true);
|
||||
return this.selection;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Node {
|
||||
void insertDelta(Delta delta, {bool insertAfter = true}) {
|
||||
assert(delta.every((element) => element is TextInsert));
|
||||
if (this.delta == null) {
|
||||
updateAttributes({
|
||||
blockComponentDelta: delta.toJson(),
|
||||
});
|
||||
} else if (insertAfter) {
|
||||
updateAttributes(
|
||||
{
|
||||
blockComponentDelta: this
|
||||
.delta!
|
||||
.compose(
|
||||
Delta()
|
||||
..retain(this.delta!.length)
|
||||
..addAll(delta),
|
||||
)
|
||||
.toJson(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updateAttributes(
|
||||
{
|
||||
blockComponentDelta: delta
|
||||
.compose(
|
||||
Delta()
|
||||
..retain(delta.length)
|
||||
..addAll(this.delta!),
|
||||
)
|
||||
.toJson(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension PasteFromHtml on EditorState {
|
||||
Future<void> pasteHtml(String html) async {
|
||||
final nodes = htmlToDocument(html).root.children.toList();
|
||||
// remove the front and back empty line
|
||||
while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) {
|
||||
nodes.removeAt(0);
|
||||
}
|
||||
while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) {
|
||||
nodes.removeLast();
|
||||
}
|
||||
if (nodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (nodes.length == 1) {
|
||||
await pasteSingleLineNode(nodes.first);
|
||||
} else {
|
||||
await pasteMultiLineNodes(nodes.toList());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
extension PasteFromImage on EditorState {
|
||||
static final supportedImageFormats = [
|
||||
'png',
|
||||
'jpeg',
|
||||
'gif',
|
||||
];
|
||||
|
||||
Future<void> pasteImage(String format, Uint8List imageBytes) async {
|
||||
if (!supportedImageFormats.contains(format)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(
|
||||
path,
|
||||
'images',
|
||||
);
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
final copyToPath = p.join(
|
||||
imagePath,
|
||||
'${uuid()}.$format',
|
||||
);
|
||||
await File(copyToPath).writeAsBytes(imageBytes);
|
||||
await insertImageNode(copyToPath);
|
||||
} catch (e) {
|
||||
Log.error('cannot copy image file', e);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
|
||||
extension PasteFromInAppJson on EditorState {
|
||||
Future<void> pasteInAppJson(String inAppJson) async {
|
||||
try {
|
||||
final nodes = Document.fromJson(jsonDecode(inAppJson)).root.children;
|
||||
if (nodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (nodes.length == 1) {
|
||||
await pasteSingleLineNode(nodes.first);
|
||||
} else {
|
||||
await pasteMultiLineNodes(nodes.toList());
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error(
|
||||
'Failed to paste in app json: $inAppJson, error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
RegExp _hrefRegex = RegExp(
|
||||
r'https?://(?:www\.)?[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(?:/[^\s]*)?',
|
||||
);
|
||||
|
||||
extension PasteFromPlainText on EditorState {
|
||||
Future<void> pastePlainText(String plainText) async {
|
||||
final nodes = plainText
|
||||
.split('\n')
|
||||
.map(
|
||||
(e) => e
|
||||
..replaceAll(r'\r', '')
|
||||
..trimRight(),
|
||||
)
|
||||
.map((e) {
|
||||
// parse the url content
|
||||
final Attributes attributes = {};
|
||||
if (_hrefRegex.hasMatch(e)) {
|
||||
attributes[AppFlowyRichTextKeys.href] = e;
|
||||
}
|
||||
return Delta()..insert(e, attributes: attributes);
|
||||
})
|
||||
.map((e) => paragraphNode(delta: e))
|
||||
.toList();
|
||||
if (nodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (nodes.length == 1) {
|
||||
await pasteSingleLineNode(nodes.first);
|
||||
} else {
|
||||
await pasteMultiLineNodes(nodes.toList());
|
||||
}
|
||||
}
|
||||
}
|
@ -3,6 +3,8 @@ export 'actions/option_action.dart';
|
||||
export 'callout/callout_block_component.dart';
|
||||
export 'code_block/code_block_component.dart';
|
||||
export 'code_block/code_block_shortcut_event.dart';
|
||||
export 'copy_and_paste/custom_copy_command.dart';
|
||||
export 'copy_and_paste/custom_paste_command.dart';
|
||||
export 'database/database_view_block_component.dart';
|
||||
export 'database/inline_database_menu_item.dart';
|
||||
export 'database/referenced_database_menu_item.dart';
|
||||
|
Reference in New Issue
Block a user