Skip to content

implement enhanced clipboard for html editor #3150

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import 'package:acter/common/toolkit/html_editor/html_editor.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:logging/logging.dart';
import 'package:super_clipboard/super_clipboard.dart';

/*
* This file contains code derived from AppFlowy
* Original source: https://github.com/AppFlowy-IO/AppFlowy
* Licensed under AGPL-3.0
*
* Modifications made: Enhanced with clipboard event handling
* Date of derivation: 27 June 2025
*/
final _log = Logger(

Check warning on line 14 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L14

Added line #L14 was not covered by tests
'a3::common::toolkit::html_editor::clipboard::commands::custom_copy_handler',
);

class CustomCopyHandler {
static EditorState? _currentEditorState;
static bool _isInitialized = false;

static void initialize(EditorState editorState) {
_currentEditorState = editorState;

if (!_isInitialized) {
_initClipboardEvents();
_isInitialized = true;
}
}

static void _initClipboardEvents() {
final events = ClipboardEvents.instance;
if (events == null) {
// clipboard events are only supported on web
return;
}

// copy event listener for external clipboard events
events.registerCopyEventListener((event) async {

Check warning on line 39 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L39

Added line #L39 was not covered by tests
final editorState = _currentEditorState;
if (editorState == null) return;

await _handleCopyEvent(editorState, event, isCut: false);

Check warning on line 43 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L43

Added line #L43 was not covered by tests
});

// cut event listener for external clipboard events
events.registerCutEventListener((event) async {

Check warning on line 47 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L47

Added line #L47 was not covered by tests
final editorState = _currentEditorState;
if (editorState == null) return;

await _handleCopyEvent(editorState, event, isCut: true);

Check warning on line 51 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L51

Added line #L51 was not covered by tests
});
}

static Future<void> _handleCopyEvent(

Check warning on line 55 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L55

Added line #L55 was not covered by tests
EditorState editorState,
ClipboardWriteEvent event, {
bool isCut = false,
}) async {
final selection = editorState.selection?.normalized;
if (selection == null || selection.isCollapsed) {

Check warning on line 61 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L60-L61

Added lines #L60 - L61 were not covered by tests
return;
}

try {
final markdown = editorState.intoMarkdown();

Check warning on line 66 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L66

Added line #L66 was not covered by tests

final item = DataWriterItem();

Check warning on line 68 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L68

Added line #L68 was not covered by tests

item.add(Formats.plainText(markdown));

Check warning on line 70 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L70

Added line #L70 was not covered by tests

await event.write([item]);

Check warning on line 72 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L72

Added line #L72 was not covered by tests

// if is cut, delete the selection also
if (isCut) {
await editorState.deleteSelection(selection);

Check warning on line 76 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L76

Added line #L76 was not covered by tests
}
} catch (e) {
_log.info('Error handling copy/cut event: $e');

Check warning on line 79 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L79

Added line #L79 was not covered by tests
}
}

static void dispose() {
final events = ClipboardEvents.instance;
if (events != null) {
// unregister the copy and cut event listeners
events.unregisterCutEventListener((_) => _initClipboardEvents);
events.unregisterCopyEventListener((_) => _initClipboardEvents);

Check warning on line 88 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart#L87-L88

Added lines #L87 - L88 were not covered by tests
}
_currentEditorState = null;
_isInitialized = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'package:acter/common/toolkit/html_editor/services/clipboard_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:super_clipboard/super_clipboard.dart';

/*
* This file contains code derived from AppFlowy
* Original source: https://github.com/AppFlowy-IO/AppFlowy
* Licensed under AGPL-3.0
*
* Modifications made: Enhanced with clipboard event handling
* Date of derivation: 27 June 2025
*/
final _log = Logger(

Check warning on line 15 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L15

Added line #L15 was not covered by tests
'a3::common::toolkit::html_editor::commands::custom_paste_command',
);

final List<CommandShortcutEvent> customPasteCommands = [customPasteCommand];

final CommandShortcutEvent customPasteCommand = CommandShortcutEvent(
key: 'paste the content',
getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent,

Check warning on line 23 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L23

Added line #L23 was not covered by tests
command: 'ctrl+v',
macOSCommand: 'cmd+v',
handler: _customPasteCommandHandler,
);

CommandShortcutEventHandler _customPasteCommandHandler = (editorState) {
final selection = editorState.selection;

Check warning on line 30 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L30

Added line #L30 was not covered by tests
if (selection == null) {
return KeyEventResult.ignored;
}

() async {
final data = await HtmlEditorClipboardService().getFormattedText();
final richText = data.richText;
if (richText == null || richText.isEmpty) {

Check warning on line 38 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L35-L38

Added lines #L35 - L38 were not covered by tests
return false;
}

// shared paste logic
await CustomPasteHandler._handlePasteContent(editorState, richText);

Check warning on line 43 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L43

Added line #L43 was not covered by tests
return true;
}();

Check warning on line 45 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L45

Added line #L45 was not covered by tests

return KeyEventResult.handled;
};

class CustomPasteHandler {
static EditorState? _currentEditorState;
static bool _isInitialized = false;

static void initialize(EditorState editorState) {
_currentEditorState = editorState;

if (!_isInitialized) {
_initClipboardEvents();
_isInitialized = true;
}
}

static void _initClipboardEvents() {
final events = ClipboardEvents.instance;
if (events == null) {
// clipboard events are only supported on web
return;
}

events.registerPasteEventListener((event) async {

Check warning on line 70 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L70

Added line #L70 was not covered by tests
final editorState = _currentEditorState;
if (editorState == null) return;

final reader = await event.getClipboardReader();

Check warning on line 74 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L74

Added line #L74 was not covered by tests

// try to get html first
String? content;
if (reader.canProvide(Formats.htmlText)) {
content = await reader.readValue(Formats.htmlText);
} else if (reader.canProvide(Formats.plainText)) {
content = await reader.readValue(Formats.plainText);

Check warning on line 81 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L78-L81

Added lines #L78 - L81 were not covered by tests
}

if (content != null && content.isNotEmpty) {
await _handlePasteContent(editorState, content);

Check warning on line 85 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L84-L85

Added lines #L84 - L85 were not covered by tests
}
});
}

static Future<bool> _handlePasteContent(

Check warning on line 90 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L90

Added line #L90 was not covered by tests
EditorState editorState,
String content,
) async {
final selection = editorState.selection;

Check warning on line 94 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L94

Added line #L94 was not covered by tests
if (selection == null) {
return false;
}

try {
final nodes = htmlToDocument(content).root.children.toList();

Check warning on line 100 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L100

Added line #L100 was not covered by tests

// Remove empty nodes from front and back
while (nodes.isNotEmpty &&
nodes.first.delta?.isEmpty == true &&
nodes.first.children.isEmpty) {
nodes.removeAt(0);

Check warning on line 106 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L103-L106

Added lines #L103 - L106 were not covered by tests
}
while (nodes.isNotEmpty &&
nodes.last.delta?.isEmpty == true &&
nodes.last.children.isEmpty) {
nodes.removeLast();

Check warning on line 111 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L108-L111

Added lines #L108 - L111 were not covered by tests
}

if (nodes.isEmpty) {

Check warning on line 114 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L114

Added line #L114 was not covered by tests
return false;
}

if (nodes.length == 1) {
await editorState.pasteSingleLineNode(nodes.first);

Check warning on line 119 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L118-L119

Added lines #L118 - L119 were not covered by tests
} else {
await editorState.pasteMultiLineNodes(nodes.toList());

Check warning on line 121 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L121

Added line #L121 was not covered by tests
}

return true;
} catch (e) {
_log.info('Error pasting content: $e');

Check warning on line 126 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L126

Added line #L126 was not covered by tests
return false;
}
}

static void dispose() {
final events = ClipboardEvents.instance;
if (events != null) {
// unregister the paste event listener
events.unregisterPasteEventListener((_) => _initClipboardEvents);

Check warning on line 135 in app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart#L135

Added line #L135 was not covered by tests
}
_currentEditorState = null;
_isInitialized = false;
}
}
63 changes: 44 additions & 19 deletions app/lib/common/toolkit/html_editor/html_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
import 'package:acter/common/toolkit/buttons/primary_action_button.dart';
import 'package:acter/common/toolkit/buttons/user_chip.dart';
import 'package:acter/common/toolkit/html/utils.dart';
import 'package:acter/common/toolkit/html_editor/clipboard/commands/custom_copy_handler.dart';
import 'package:acter/common/toolkit/html_editor/clipboard/commands/custom_paste_command.dart';
import 'package:acter/common/toolkit/html_editor/mentions/commands/mention_movements.dart';
import 'package:acter/common/toolkit/html_editor/mentions/mention_detection.dart';
import 'package:acter/common/toolkit/html_editor/services/clipboard_service.dart';
import 'package:acter/config/constants.dart';
import 'package:acter/common/toolkit/html_editor/services/constants.dart';
import 'package:acter/common/toolkit/html_editor/mentions/mention_shortcuts.dart';
Expand Down Expand Up @@ -229,6 +232,8 @@
]);

updateEditorState(widget.editorState ?? EditorState.blank());
CustomCopyHandler.initialize(editorState);
CustomPasteHandler.initialize(editorState);
}

@override
Expand All @@ -245,6 +250,8 @@
_updateEditorHeight,
);
_changeListener?.cancel();
CustomCopyHandler.dispose();
CustomPasteHandler.dispose();
super.dispose();
}

Expand Down Expand Up @@ -355,6 +362,16 @@
];
}

List<CommandShortcutEvent> _buildCommandShortcutEvents() {
return [
...standardCommandShortcutEvents.where(
(e) => e != pasteCommand && e != pasteTextWithoutFormattingCommand,
),
...mentionMenuCommandShortcutEvents,
...customPasteCommands,
];
}

Widget? generateFooter() {
if (widget.footer != null) {
return widget.footer;
Expand Down Expand Up @@ -450,7 +467,16 @@
closeToolbar();
},
onCut: () => cutCommand.execute(editorState),
onPaste: () => pasteCommand.execute(editorState),
onPaste: () async {
final data = await HtmlEditorClipboardService()
.getFormattedText();
final richText = data.richText;
if (richText != null && richText.isNotEmpty) {
customPasteCommand.execute(editorState);

Check warning on line 475 in app/lib/common/toolkit/html_editor/html_editor.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/html_editor.dart#L470-L475

Added lines #L470 - L475 were not covered by tests
} else {
pasteCommand.execute(editorState);

Check warning on line 477 in app/lib/common/toolkit/html_editor/html_editor.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/html_editor.dart#L477

Added line #L477 was not covered by tests
}
},
onSelectAll: () => selectAllCommand.execute(editorState),
onLiveTextInput: null,
onLookUp: null,
Expand Down Expand Up @@ -478,10 +504,7 @@
footer: generateFooter(),
blockComponentBuilders: _buildBlockComponentBuilders(),
characterShortcutEvents: _buildCharacterShortcutEvents(),
commandShortcutEvents: [
...mentionMenuCommandShortcutEvents,
...standardCommandShortcutEvents,
],
commandShortcutEvents: _buildCommandShortcutEvents(),
disableAutoScroll: false,
autoScrollEdgeOffset: 20,
);
Expand All @@ -492,14 +515,13 @@

return ValueListenableBuilder(
valueListenable: _contentHeightNotifier,
builder:
(context, value, child) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOutCubic,
width: MediaQuery.sizeOf(context).width,
height: max(value, widget.minHeight ?? 50),
child: editor,
),
builder: (context, value, child) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOutCubic,
width: MediaQuery.sizeOf(context).width,
height: max(value, widget.minHeight ?? 50),
child: editor,
),
);
}

Expand All @@ -516,8 +538,9 @@
).textTheme.bodySmall.expect('bodySmall style not available'),
lineHeight: 1.0,
),
textSpanDecorator:
widget.roomId != null ? customizeAttributeDecorator : null,
textSpanDecorator: widget.roomId != null
? customizeAttributeDecorator

Check warning on line 542 in app/lib/common/toolkit/html_editor/html_editor.dart

View check run for this annotation

Codecov / codecov/patch

app/lib/common/toolkit/html_editor/html_editor.dart#L541-L542

Added lines #L541 - L542 were not covered by tests
: null,
);
}

Expand All @@ -534,10 +557,12 @@
).textTheme.bodySmall.expect('bodySmall style not available'),
lineHeight: 1.0,
),
textSpanDecorator:
widget.roomId != null ? customizeAttributeDecorator : null,
mobileDragHandleBallSize:
Platform.isIOS ? const Size.square(16) : const Size.square(12),
textSpanDecorator: widget.roomId != null
? customizeAttributeDecorator
: null,
mobileDragHandleBallSize: Platform.isIOS
? const Size.square(16)
: const Size.square(12),
);
}

Expand Down
Loading
Loading