mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support prompt and suffix
This commit is contained in:
parent
494e31993b
commit
fc1efeb70b
@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:example/pages/simple_editor.dart';
|
import 'package:example/pages/simple_editor.dart';
|
||||||
import 'package:example/plugin/text_robot.dart';
|
import 'package:example/plugin/AI/text_robot.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -2,7 +2,9 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
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:example/plugin/text_robot.dart';
|
import 'package:example/plugin/AI/continue_to_write.dart';
|
||||||
|
import 'package:example/plugin/AI/auto_completion.dart';
|
||||||
|
import 'package:example/plugin/AI/getgpt3completions.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SimpleEditor extends StatelessWidget {
|
class SimpleEditor extends StatelessWidget {
|
||||||
@ -65,8 +67,11 @@ class SimpleEditor extends StatelessWidget {
|
|||||||
codeBlockMenuItem,
|
codeBlockMenuItem,
|
||||||
// Emoji
|
// Emoji
|
||||||
emojiMenuItem,
|
emojiMenuItem,
|
||||||
// Text Robot
|
// Open AI
|
||||||
textRobotMenuItem,
|
if (apiKey.isNotEmpty) ...[
|
||||||
|
autoCompletionMenuItem,
|
||||||
|
continueToWriteMenuItem,
|
||||||
|
]
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:example/plugin/AI/getgpt3completions.dart';
|
import 'package:example/plugin/AI/getgpt3completions.dart';
|
||||||
|
import 'package:example/plugin/AI/text_robot.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
|
SelectionMenuItem autoCompletionMenuItem = SelectionMenuItem(
|
||||||
name: () => 'Open AI',
|
name: () => 'Auto generate content',
|
||||||
icon: (editorState, onSelected) => Icon(
|
icon: (editorState, onSelected) => Icon(
|
||||||
Icons.rocket,
|
Icons.rocket,
|
||||||
size: 18.0,
|
size: 18.0,
|
||||||
@ -12,7 +13,7 @@ SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
|
|||||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||||
),
|
),
|
||||||
keywords: ['open ai', 'gpt3', 'ai'],
|
keywords: ['auto generate content', 'open ai', 'gpt3', 'ai'],
|
||||||
handler: ((editorState, menuService, context) async {
|
handler: ((editorState, menuService, context) async {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -35,11 +36,11 @@ SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
|
|||||||
if (key.logicalKey == LogicalKeyboardKey.enter) {
|
if (key.logicalKey == LogicalKeyboardKey.enter) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
// fetch the result and insert it
|
// fetch the result and insert it
|
||||||
// Please fill in your own API key
|
final textRobot = TextRobot(editorState: editorState);
|
||||||
getGPT3Completion('', controller.text, '', 200, .3,
|
getGPT3Completion(apiKey, controller.text, '', (result) async {
|
||||||
(result) async {
|
await textRobot.insertText(
|
||||||
await editorState.insertTextAtCurrentSelection(
|
|
||||||
result,
|
result,
|
||||||
|
inputType: TextRobotInputType.character,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
@ -52,44 +53,3 @@ SelectionMenuItem textRobotMenuItem = SelectionMenuItem(
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
enum TextRobotInputType {
|
|
||||||
character,
|
|
||||||
word,
|
|
||||||
}
|
|
||||||
|
|
||||||
class TextRobot {
|
|
||||||
const TextRobot({
|
|
||||||
required this.editorState,
|
|
||||||
this.delay = const Duration(milliseconds: 30),
|
|
||||||
});
|
|
||||||
|
|
||||||
final EditorState editorState;
|
|
||||||
final Duration delay;
|
|
||||||
|
|
||||||
Future<void> insertText(
|
|
||||||
String text, {
|
|
||||||
TextRobotInputType inputType = TextRobotInputType.character,
|
|
||||||
}) async {
|
|
||||||
final lines = text.split('\n');
|
|
||||||
for (final line in lines) {
|
|
||||||
switch (inputType) {
|
|
||||||
case TextRobotInputType.character:
|
|
||||||
final iterator = line.runes.iterator;
|
|
||||||
while (iterator.moveNext()) {
|
|
||||||
await editorState.insertTextAtCurrentSelection(
|
|
||||||
iterator.currentAsString,
|
|
||||||
);
|
|
||||||
await Future.delayed(delay);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert new line
|
|
||||||
if (lines.length > 1) {
|
|
||||||
await editorState.insertNewLine(editorState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,50 @@
|
|||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:example/plugin/AI/getgpt3completions.dart';
|
||||||
|
import 'package:example/plugin/AI/text_robot.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
SelectionMenuItem continueToWriteMenuItem = SelectionMenuItem(
|
||||||
|
name: () => 'Continue To Write',
|
||||||
|
icon: (editorState, onSelected) => Icon(
|
||||||
|
Icons.print,
|
||||||
|
size: 18.0,
|
||||||
|
color: onSelected
|
||||||
|
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||||
|
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||||
|
),
|
||||||
|
keywords: ['continue to write'],
|
||||||
|
handler: ((editorState, menuService, context) async {
|
||||||
|
// get the current text
|
||||||
|
final selection =
|
||||||
|
editorState.service.selectionService.currentSelection.value;
|
||||||
|
final textNodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
|
if (selection == null || !selection.isCollapsed || textNodes.length != 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final textNode = textNodes.first as TextNode;
|
||||||
|
final prompt = textNode.delta.slice(0, selection.startIndex).toPlainText();
|
||||||
|
final suffix = textNode.delta
|
||||||
|
.slice(
|
||||||
|
selection.endIndex,
|
||||||
|
textNode.toPlainText().length,
|
||||||
|
)
|
||||||
|
.toPlainText();
|
||||||
|
debugPrint('AI: prompt = $prompt, suffix = $suffix');
|
||||||
|
final textRobot = TextRobot(editorState: editorState);
|
||||||
|
getGPT3Completion(
|
||||||
|
apiKey,
|
||||||
|
prompt,
|
||||||
|
suffix,
|
||||||
|
(result) async {
|
||||||
|
if (result == '\\n') {
|
||||||
|
await editorState.insertNewLineAtCurrentSelection();
|
||||||
|
} else {
|
||||||
|
await textRobot.insertText(
|
||||||
|
result,
|
||||||
|
inputType: TextRobotInputType.word,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
@ -2,14 +2,19 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
// Please fill in your own API key
|
||||||
|
const apiKey = '';
|
||||||
|
|
||||||
Future<void> getGPT3Completion(
|
Future<void> getGPT3Completion(
|
||||||
String apiKey,
|
String apiKey,
|
||||||
String prompt,
|
String prompt,
|
||||||
String suffix,
|
String suffix,
|
||||||
int maxTokens,
|
Future<void> Function(String)
|
||||||
double temperature,
|
onData, // callback function to handle streaming data
|
||||||
Function(String) onData, // callback function to handle streaming data
|
{
|
||||||
) async {
|
int maxTokens = 200,
|
||||||
|
double temperature = .3,
|
||||||
|
}) async {
|
||||||
final data = {
|
final data = {
|
||||||
'prompt': prompt,
|
'prompt': prompt,
|
||||||
'suffix': suffix,
|
'suffix': suffix,
|
||||||
@ -43,7 +48,6 @@ Future<void> getGPT3Completion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final processedText = text
|
final processedText = text
|
||||||
.replaceAll('\\n', '\n')
|
|
||||||
.replaceAll('\\r', '\r')
|
.replaceAll('\\r', '\r')
|
||||||
.replaceAll('\\t', '\t')
|
.replaceAll('\\t', '\t')
|
||||||
.replaceAll('\\b', '\b')
|
.replaceAll('\\b', '\b')
|
||||||
@ -62,7 +66,7 @@ Future<void> getGPT3Completion(
|
|||||||
.replaceAll('\\8', '8')
|
.replaceAll('\\8', '8')
|
||||||
.replaceAll('\\9', '9');
|
.replaceAll('\\9', '9');
|
||||||
|
|
||||||
onData(processedText);
|
await onData(processedText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
|
enum TextRobotInputType {
|
||||||
|
character,
|
||||||
|
word,
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextRobot {
|
||||||
|
const TextRobot({
|
||||||
|
required this.editorState,
|
||||||
|
this.delay = const Duration(milliseconds: 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
final EditorState editorState;
|
||||||
|
final Duration delay;
|
||||||
|
|
||||||
|
Future<void> insertText(
|
||||||
|
String text, {
|
||||||
|
TextRobotInputType inputType = TextRobotInputType.character,
|
||||||
|
}) async {
|
||||||
|
final lines = text.split('\\n');
|
||||||
|
for (final line in lines) {
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
switch (inputType) {
|
||||||
|
case TextRobotInputType.character:
|
||||||
|
final iterator = line.runes.iterator;
|
||||||
|
while (iterator.moveNext()) {
|
||||||
|
await editorState.insertTextAtCurrentSelection(
|
||||||
|
iterator.currentAsString,
|
||||||
|
);
|
||||||
|
await Future.delayed(delay, () {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TextRobotInputType.word:
|
||||||
|
await editorState.insertTextAtCurrentSelection(
|
||||||
|
line,
|
||||||
|
);
|
||||||
|
await Future.delayed(delay, () {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert new line
|
||||||
|
if (lines.length > 1) {
|
||||||
|
await editorState.insertNewLineAtCurrentSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,25 +12,21 @@ extension TextCommands on EditorState {
|
|||||||
Path? path,
|
Path? path,
|
||||||
TextNode? textNode,
|
TextNode? textNode,
|
||||||
}) async {
|
}) async {
|
||||||
return futureCommand(() {
|
final n = getTextNode(path: path, textNode: textNode);
|
||||||
final n = getTextNode(path: path, textNode: textNode);
|
return apply(
|
||||||
apply(
|
transaction..insertText(n, index, text),
|
||||||
transaction..insertText(n, index, text),
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> insertTextAtCurrentSelection(String text) async {
|
Future<void> insertTextAtCurrentSelection(String text) async {
|
||||||
return futureCommand(() async {
|
final selection = getSelection(null);
|
||||||
final selection = getSelection(null);
|
assert(selection.isCollapsed);
|
||||||
assert(selection.isCollapsed);
|
final textNode = getTextNode(path: selection.start.path);
|
||||||
final textNode = getTextNode(path: selection.start.path);
|
return insertText(
|
||||||
await insertText(
|
selection.startIndex,
|
||||||
textNode.toPlainText().length,
|
text,
|
||||||
text,
|
textNode: textNode,
|
||||||
textNode: textNode,
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> formatText(
|
Future<void> formatText(
|
||||||
@ -40,13 +36,11 @@ extension TextCommands on EditorState {
|
|||||||
Path? path,
|
Path? path,
|
||||||
TextNode? textNode,
|
TextNode? textNode,
|
||||||
}) async {
|
}) async {
|
||||||
return futureCommand(() {
|
final n = getTextNode(path: path, textNode: textNode);
|
||||||
final n = getTextNode(path: path, textNode: textNode);
|
final s = getSelection(selection);
|
||||||
final s = getSelection(selection);
|
return apply(
|
||||||
apply(
|
transaction..formatText(n, s.startIndex, s.length, attributes),
|
||||||
transaction..formatText(n, s.startIndex, s.length, attributes),
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> formatTextWithBuiltInAttribute(
|
Future<void> formatTextWithBuiltInAttribute(
|
||||||
@ -57,26 +51,24 @@ extension TextCommands on EditorState {
|
|||||||
Path? path,
|
Path? path,
|
||||||
TextNode? textNode,
|
TextNode? textNode,
|
||||||
}) async {
|
}) async {
|
||||||
return futureCommand(() {
|
final n = getTextNode(path: path, textNode: textNode);
|
||||||
final n = getTextNode(path: path, textNode: textNode);
|
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
|
||||||
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
|
final attr = n.attributes
|
||||||
final attr = n.attributes
|
..removeWhere(
|
||||||
..removeWhere(
|
(key, _) => BuiltInAttributeKey.globalStyleKeys.contains(key))
|
||||||
(key, _) => BuiltInAttributeKey.globalStyleKeys.contains(key))
|
..addAll(attributes)
|
||||||
..addAll(attributes)
|
..addAll({
|
||||||
..addAll({
|
BuiltInAttributeKey.subtype: key,
|
||||||
BuiltInAttributeKey.subtype: key,
|
});
|
||||||
});
|
return apply(
|
||||||
apply(
|
transaction..updateNode(n, attr),
|
||||||
transaction..updateNode(n, attr),
|
);
|
||||||
);
|
} else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
|
||||||
} else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
|
final s = getSelection(selection);
|
||||||
final s = getSelection(selection);
|
return apply(
|
||||||
apply(
|
transaction..formatText(n, s.startIndex, s.length, attributes),
|
||||||
transaction..formatText(n, s.startIndex, s.length, attributes),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> formatTextToCheckbox(
|
Future<void> formatTextToCheckbox(
|
||||||
@ -109,19 +101,28 @@ extension TextCommands on EditorState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> insertNewLine(
|
Future<void> insertNewLine({
|
||||||
EditorState editorState, {
|
|
||||||
Path? path,
|
Path? path,
|
||||||
}) async {
|
}) async {
|
||||||
return futureCommand(() async {
|
final p = path ?? getSelection(null).start.path.next;
|
||||||
final p = path ?? getSelection(null).start.path.next;
|
final transaction = this.transaction;
|
||||||
final transaction = editorState.transaction;
|
transaction.insertNode(p, TextNode.empty());
|
||||||
transaction.insertNode(p, TextNode.empty());
|
transaction.afterSelection = Selection.single(
|
||||||
transaction.afterSelection = Selection.single(
|
path: p,
|
||||||
path: p,
|
startOffset: 0,
|
||||||
startOffset: 0,
|
);
|
||||||
);
|
return apply(transaction);
|
||||||
apply(transaction);
|
}
|
||||||
});
|
|
||||||
|
Future<void> insertNewLineAtCurrentSelection() async {
|
||||||
|
final selection = getSelection(null);
|
||||||
|
assert(selection.isCollapsed);
|
||||||
|
final textNode = getTextNode(path: selection.start.path);
|
||||||
|
final transaction = this.transaction;
|
||||||
|
transaction.splitText(
|
||||||
|
textNode,
|
||||||
|
selection.startIndex,
|
||||||
|
);
|
||||||
|
return apply(transaction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -169,6 +169,25 @@ extension TextTransaction on Transaction {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void splitText(TextNode textNode, int offset) {
|
||||||
|
final delta = textNode.delta;
|
||||||
|
final first = delta.slice(0, offset);
|
||||||
|
final second = delta.slice(offset, delta.length);
|
||||||
|
final path = textNode.path.next;
|
||||||
|
updateText(textNode, first);
|
||||||
|
insertNode(
|
||||||
|
path,
|
||||||
|
TextNode(
|
||||||
|
attributes: textNode.attributes,
|
||||||
|
delta: second,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
afterSelection = Selection.collapsed(Position(
|
||||||
|
path: path,
|
||||||
|
offset: 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts the text content at a specified index.
|
/// Inserts the text content at a specified index.
|
||||||
///
|
///
|
||||||
/// Optionally, you may specify formatting attributes that are applied to the inserted string.
|
/// Optionally, you may specify formatting attributes that are applied to the inserted string.
|
||||||
|
@ -96,13 +96,21 @@ class EditorState {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCursorSelection(Selection? cursorSelection,
|
Future<void> updateCursorSelection(
|
||||||
[CursorUpdateReason reason = CursorUpdateReason.others]) {
|
Selection? cursorSelection, [
|
||||||
|
CursorUpdateReason reason = CursorUpdateReason.others,
|
||||||
|
]) {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
|
||||||
// broadcast to other users here
|
// broadcast to other users here
|
||||||
if (reason != CursorUpdateReason.uiEvent) {
|
if (reason != CursorUpdateReason.uiEvent) {
|
||||||
service.selectionService.updateSelection(cursorSelection);
|
service.selectionService.updateSelection(cursorSelection);
|
||||||
}
|
}
|
||||||
_cursorSelection = cursorSelection;
|
_cursorSelection = cursorSelection;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||||
|
completer.complete();
|
||||||
|
});
|
||||||
|
return completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _debouncedSealHistoryItemTimer;
|
Timer? _debouncedSealHistoryItemTimer;
|
||||||
@ -121,14 +129,17 @@ class EditorState {
|
|||||||
///
|
///
|
||||||
/// The options can be used to determine whether the editor
|
/// The options can be used to determine whether the editor
|
||||||
/// should record the transaction in undo/redo stack.
|
/// should record the transaction in undo/redo stack.
|
||||||
void apply(
|
Future<void> apply(
|
||||||
Transaction transaction, {
|
Transaction transaction, {
|
||||||
ApplyOptions options = const ApplyOptions(recordUndo: true),
|
ApplyOptions options = const ApplyOptions(recordUndo: true),
|
||||||
ruleCount = 0,
|
ruleCount = 0,
|
||||||
withUpdateCursor = true,
|
withUpdateCursor = true,
|
||||||
}) {
|
}) async {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
|
||||||
if (!editable) {
|
if (!editable) {
|
||||||
return;
|
completer.complete();
|
||||||
|
return completer.future;
|
||||||
}
|
}
|
||||||
// TODO: validate the transation.
|
// TODO: validate the transation.
|
||||||
for (final op in transaction.operations) {
|
for (final op in transaction.operations) {
|
||||||
@ -137,10 +148,11 @@ class EditorState {
|
|||||||
|
|
||||||
_observer.add(transaction);
|
_observer.add(transaction);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
_applyRules(ruleCount);
|
_applyRules(ruleCount);
|
||||||
if (withUpdateCursor) {
|
if (withUpdateCursor) {
|
||||||
updateCursorSelection(transaction.afterSelection);
|
await updateCursorSelection(transaction.afterSelection);
|
||||||
|
completer.complete();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -160,6 +172,8 @@ class EditorState {
|
|||||||
redoItem.afterSelection = transaction.afterSelection;
|
redoItem.afterSelection = transaction.afterSelection;
|
||||||
undoManager.redoStack.push(redoItem);
|
undoManager.redoStack.push(redoItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _debouncedSealHistoryItem() {
|
void _debouncedSealHistoryItem() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user