feat: add document rules (#1071)
This commit is contained in:
parent
a583d139d2
commit
f2685cb900
6 changed files with 120 additions and 0 deletions
|
|
@ -6,6 +6,8 @@ export 'document/document.dart';
|
||||||
export 'document/node.dart';
|
export 'document/node.dart';
|
||||||
export 'document/node_iterator.dart';
|
export 'document/node_iterator.dart';
|
||||||
export 'document/path.dart';
|
export 'document/path.dart';
|
||||||
|
export 'document/rules/at_least_one_editable_node_rule.dart';
|
||||||
|
export 'document/rules/document_rule.dart';
|
||||||
export 'document/text_delta.dart';
|
export 'document/text_delta.dart';
|
||||||
export 'legacy/built_in_attribute_keys.dart';
|
export 'legacy/built_in_attribute_keys.dart';
|
||||||
export 'location/position.dart';
|
export 'location/position.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
|
/// This rule ensures that there is at least one editable node in the document.
|
||||||
|
///
|
||||||
|
/// If the document is empty, it will create a new paragraph node.
|
||||||
|
class AtLeastOneEditableNodeRule extends DocumentRule {
|
||||||
|
const AtLeastOneEditableNodeRule();
|
||||||
|
@override
|
||||||
|
bool shouldApply({
|
||||||
|
required EditorState editorState,
|
||||||
|
required EditorTransactionValue value,
|
||||||
|
}) {
|
||||||
|
final time = value.$1;
|
||||||
|
if (time != TransactionTime.after) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final document = editorState.document;
|
||||||
|
return document.root.children.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> apply({
|
||||||
|
required EditorState editorState,
|
||||||
|
required EditorTransactionValue value,
|
||||||
|
}) async {
|
||||||
|
final transaction = editorState.transaction;
|
||||||
|
transaction
|
||||||
|
..insertNode([0], paragraphNode())
|
||||||
|
..afterSelection = Selection.collapsed(
|
||||||
|
Position(path: [0]),
|
||||||
|
);
|
||||||
|
editorState.apply(transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
lib/src/core/document/rules/document_rule.dart
Normal file
17
lib/src/core/document/rules/document_rule.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
|
abstract class DocumentRule {
|
||||||
|
const DocumentRule();
|
||||||
|
|
||||||
|
/// Whether the rule should be applied.
|
||||||
|
bool shouldApply({
|
||||||
|
required EditorState editorState,
|
||||||
|
required EditorTransactionValue value,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Apply the rule to the document.
|
||||||
|
Future<void> apply({
|
||||||
|
required EditorState editorState,
|
||||||
|
required EditorTransactionValue value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ class AppFlowyEditor extends StatefulWidget {
|
||||||
this.disableScrollService = false,
|
this.disableScrollService = false,
|
||||||
this.disableAutoScroll = false,
|
this.disableAutoScroll = false,
|
||||||
this.autoScrollEdgeOffset = appFlowyEditorAutoScrollEdgeOffset,
|
this.autoScrollEdgeOffset = appFlowyEditorAutoScrollEdgeOffset,
|
||||||
|
this.documentRules = const [],
|
||||||
}) : blockComponentBuilders =
|
}) : blockComponentBuilders =
|
||||||
blockComponentBuilders ?? standardBlockComponentBuilderMap,
|
blockComponentBuilders ?? standardBlockComponentBuilderMap,
|
||||||
characterShortcutEvents =
|
characterShortcutEvents =
|
||||||
|
|
@ -225,6 +226,10 @@ class AppFlowyEditor extends StatefulWidget {
|
||||||
///
|
///
|
||||||
final double autoScrollEdgeOffset;
|
final double autoScrollEdgeOffset;
|
||||||
|
|
||||||
|
/// The rules to apply to the document.
|
||||||
|
///
|
||||||
|
final List<DocumentRule> documentRules;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
|
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +377,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||||
editorState.autoCompleteTextProvider = widget.autoCompleteTextProvider;
|
editorState.autoCompleteTextProvider = widget.autoCompleteTextProvider;
|
||||||
editorState.disableAutoScroll = widget.disableAutoScroll;
|
editorState.disableAutoScroll = widget.disableAutoScroll;
|
||||||
editorState.autoScrollEdgeOffset = widget.autoScrollEdgeOffset;
|
editorState.autoScrollEdgeOffset = widget.autoScrollEdgeOffset;
|
||||||
|
editorState.documentRules = widget.documentRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
BlockComponentRendererService get _renderer => BlockComponentRenderer(
|
BlockComponentRendererService get _renderer => BlockComponentRenderer(
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,8 @@ class EditorState {
|
||||||
Stream<EditorTransactionValue> get transactionStream => _observer.stream;
|
Stream<EditorTransactionValue> get transactionStream => _observer.stream;
|
||||||
final StreamController<EditorTransactionValue> _observer =
|
final StreamController<EditorTransactionValue> _observer =
|
||||||
StreamController.broadcast(sync: true);
|
StreamController.broadcast(sync: true);
|
||||||
|
final StreamController<EditorTransactionValue> _asyncObserver =
|
||||||
|
StreamController.broadcast();
|
||||||
|
|
||||||
/// Store the toggled format style, like bold, italic, etc.
|
/// Store the toggled format style, like bold, italic, etc.
|
||||||
/// All the values must be the key from [AppFlowyRichTextKeys.supportToggled].
|
/// All the values must be the key from [AppFlowyRichTextKeys.supportToggled].
|
||||||
|
|
@ -267,6 +269,24 @@ class EditorState {
|
||||||
// only used for testing
|
// only used for testing
|
||||||
bool disableSealTimer = false;
|
bool disableSealTimer = false;
|
||||||
|
|
||||||
|
/// The rules to apply to the document.
|
||||||
|
List<DocumentRule> get documentRules => _documentRules;
|
||||||
|
List<DocumentRule> _documentRules = [];
|
||||||
|
set documentRules(List<DocumentRule> value) {
|
||||||
|
_documentRules = value;
|
||||||
|
|
||||||
|
_subscription?.cancel();
|
||||||
|
_subscription = _asyncObserver.stream.listen((value) async {
|
||||||
|
for (final rule in _documentRules) {
|
||||||
|
if (rule.shouldApply(editorState: this, value: value)) {
|
||||||
|
await rule.apply(editorState: this, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription? _subscription;
|
||||||
|
|
||||||
@Deprecated('use editorState.selection instead')
|
@Deprecated('use editorState.selection instead')
|
||||||
Selection? _cursorSelection;
|
Selection? _cursorSelection;
|
||||||
|
|
||||||
|
|
@ -354,11 +374,13 @@ class EditorState {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
isDisposed = true;
|
isDisposed = true;
|
||||||
_observer.close();
|
_observer.close();
|
||||||
|
_asyncObserver.close();
|
||||||
_debouncedSealHistoryItemTimer?.cancel();
|
_debouncedSealHistoryItemTimer?.cancel();
|
||||||
onDispose.value += 1;
|
onDispose.value += 1;
|
||||||
onDispose.dispose();
|
onDispose.dispose();
|
||||||
document.dispose();
|
document.dispose();
|
||||||
selectionNotifier.dispose();
|
selectionNotifier.dispose();
|
||||||
|
_subscription?.cancel();
|
||||||
_onScrollViewScrolledListeners.clear();
|
_onScrollViewScrolledListeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,6 +420,10 @@ class EditorState {
|
||||||
_observer.add((TransactionTime.before, transaction, options));
|
_observer.add((TransactionTime.before, transaction, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_asyncObserver.isClosed) {
|
||||||
|
_asyncObserver.add((TransactionTime.before, transaction, options));
|
||||||
|
}
|
||||||
|
|
||||||
_applyTransactionInLocal(transaction);
|
_applyTransactionInLocal(transaction);
|
||||||
|
|
||||||
// broadcast to other users here, after applying the transaction
|
// broadcast to other users here, after applying the transaction
|
||||||
|
|
@ -405,6 +431,10 @@ class EditorState {
|
||||||
_observer.add((TransactionTime.after, transaction, options));
|
_observer.add((TransactionTime.after, transaction, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_asyncObserver.isClosed) {
|
||||||
|
_asyncObserver.add((TransactionTime.after, transaction, options));
|
||||||
|
}
|
||||||
|
|
||||||
_recordRedoOrUndo(options, transaction, skipHistoryDebounce);
|
_recordRedoOrUndo(options, transaction, skipHistoryDebounce);
|
||||||
|
|
||||||
if (withUpdateSelection) {
|
if (withUpdateSelection) {
|
||||||
|
|
|
||||||
31
test/core/rules/at_least_one_editable_node_rule_test.dart
Normal file
31
test/core/rules/at_least_one_editable_node_rule_test.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('at least one editable node rule', () async {
|
||||||
|
final rule = AtLeastOneEditableNodeRule();
|
||||||
|
final node1 = paragraphNode(text: 'Hello World!');
|
||||||
|
final node2 = paragraphNode(text: 'Hello World!');
|
||||||
|
final node3 = paragraphNode(text: 'Hello World!');
|
||||||
|
final document = Document(
|
||||||
|
root: pageNode(
|
||||||
|
children: [
|
||||||
|
node1,
|
||||||
|
node2,
|
||||||
|
node3,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final editorState = EditorState(document: document)..documentRules = [rule];
|
||||||
|
// delete all the nodes
|
||||||
|
final transaction = editorState.transaction;
|
||||||
|
transaction.deleteNodes([node1, node2, node3]);
|
||||||
|
await editorState.apply(transaction);
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
final node = editorState.document.root.children[0];
|
||||||
|
expect(node.type, ParagraphBlockKeys.type);
|
||||||
|
|
||||||
|
editorState.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue