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.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';

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.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(

View file

@ -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) {

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