feat: add document rules (#1071)

This commit is contained in:
Lucas 2025-03-19 10:21:20 +08:00 • committed by GitHub
commit f2685cb900
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 120 additions and 0 deletions

View file

@ -6,6 +6,8 @@ export 'document/document.dart';
export 'document/node.dart';
export 'document/node_iterator.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 'legacy/built_in_attribute_keys.dart';
export 'location/position.dart';

View file

@ -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);
}
}

View 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,
});
}

View file

@ -46,6 +46,7 @@ class AppFlowyEditor extends StatefulWidget {
this.disableScrollService = false,
this.disableAutoScroll = false,
this.autoScrollEdgeOffset = appFlowyEditorAutoScrollEdgeOffset,
this.documentRules = const [],
}) : blockComponentBuilders =
blockComponentBuilders ?? standardBlockComponentBuilderMap,
characterShortcutEvents =
@ -225,6 +226,10 @@ class AppFlowyEditor extends StatefulWidget {
///
final double autoScrollEdgeOffset;
/// The rules to apply to the document.
///
final List<DocumentRule> documentRules;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -372,6 +377,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
editorState.autoCompleteTextProvider = widget.autoCompleteTextProvider;
editorState.disableAutoScroll = widget.disableAutoScroll;
editorState.autoScrollEdgeOffset = widget.autoScrollEdgeOffset;
editorState.documentRules = widget.documentRules;
}
BlockComponentRendererService get _renderer => BlockComponentRenderer(

View file

@ -216,6 +216,8 @@ class EditorState {
Stream<EditorTransactionValue> get transactionStream => _observer.stream;
final StreamController<EditorTransactionValue> _observer =
StreamController.broadcast(sync: true);
final StreamController<EditorTransactionValue> _asyncObserver =
StreamController.broadcast();
/// Store the toggled format style, like bold, italic, etc.
/// All the values must be the key from [AppFlowyRichTextKeys.supportToggled].
@ -267,6 +269,24 @@ class EditorState {
// only used for testing
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')
Selection? _cursorSelection;
@ -354,11 +374,13 @@ class EditorState {
void dispose() {
isDisposed = true;
_observer.close();
_asyncObserver.close();
_debouncedSealHistoryItemTimer?.cancel();
onDispose.value += 1;
onDispose.dispose();
document.dispose();
selectionNotifier.dispose();
_subscription?.cancel();
_onScrollViewScrolledListeners.clear();
}
@ -398,6 +420,10 @@ class EditorState {
_observer.add((TransactionTime.before, transaction, options));
}
if (!_asyncObserver.isClosed) {
_asyncObserver.add((TransactionTime.before, transaction, options));
}
_applyTransactionInLocal(transaction);
// broadcast to other users here, after applying the transaction
@ -405,6 +431,10 @@ class EditorState {
_observer.add((TransactionTime.after, transaction, options));
}
if (!_asyncObserver.isClosed) {
_asyncObserver.add((TransactionTime.after, transaction, options));
}
_recordRedoOrUndo(options, transaction, skipHistoryDebounce);
if (withUpdateSelection) {

View 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();
});
}