Compare commits

..
Author SHA1 Message Date
0dac8a046e
Never publish this fork
Some checks failed
Commit messages lint / commitlint (push) Has been cancelled
Halkyon Learn uses this fork as a git dependency, from
https://code.halkyon.app/project-halkyon/appflowy-editor. The package name
appflowy_editor belongs to AppFlowy on pub.dev, so `dart pub publish` should refuse
outright rather than depend on pub.dev refusing the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:45:27 +02:00
a512a3ef88
Move to file_picker v13 and device_info_plus v13 (win32 v6)
Both depend on win32 v6, like current Serverpod, so an app that also uses Serverpod can resolve the editor in one pub workspace; file_picker v10 and device_info_plus v12 need win32 v5.

file_picker v12 made its API static, returns the picked files directly and reads their content on demand (PlatformFile.readAsBytes()); v13 removed the parameters v12 had deprecated. The FilePickerService implementation keeps its interface: withData, withReadStream and lockParentWindow are ignored, allowMultiple chooses between pickFiles() and pickFile(), and saveFile() saves an empty file, since file_picker now writes the file itself and needs its content up front. The image upload reads the picked file's bytes on the web; the example app saves and opens files with the new API.

Raises the minimum to Dart v3.11 and Flutter v3.41.6 (device_info_plus v13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:45:27 +02:00
Nathan.fooo
01eccc6ee3
Merge pull request #1231 from AppFlowy-IO/fix/remote-update-scroll-jump
fix: prevent remote updates from scrolling local viewport
2026-08-06 21:47:10 +08:00
Nathan
a9697798f6 fix: prevent remote updates from scrolling local viewport 2026-08-06 20:17:15 +08:00
Lucas
41335c54ae
chore: merge flutter 3_27_4 into main branch (#1228)
* chore: merge flutter/3_27_4 to main branch

* chore: code format

* fix: unit tests
2026-07-27 15:37:27 +08:00
Nathan.fooo
b43e53469e
Merge pull request #1225 from AppFlowy-IO/fix/dispose-shrinkwrap-scroll-listeners
fix: detach shrink-wrapped editor listeners on dispose
2026-07-19 15:03:59 +08:00
Nathan
829d4edf2e fix: detach shrink-wrapped editor listeners on dispose 2026-07-19 14:25:09 +08:00
Nathan.fooo
e95e16a2ce
Merge pull request #1220 from AppFlowy-IO/fix/html-comment-leak-in-delta
fix: don't leak HTML comment content into pasted paragraph text
2026-07-07 14:09:39 +08:00
Nathan
bb995825dd fix: don't leak HTML comment content into pasted paragraph text
_parseDeltaElement's fallback branch called child.text on any
non-Element DOM node, including dom.Comment (whose .text getter
aliases its raw data). This let real HTML comments -- e.g. the
internal tracking marker Google's AI Overview panel injects when
you copy its answer text -- surface as visible garbage in the
editor. Skip non-Text nodes here, matching the dom.Text check
_parseElement already does one level up.
2026-07-07 13:30:19 +08:00
Lucas
6fbe7ba442
fix: selection race condition (#1194)
* fix: guard debounced desktop selection update

Avoid dereferencing a cleared selection from the delayed
macOS metrics callback and add a regression test that
covers the stale-timer path.

* chore: clean up analyzer warnings

Remove redundant default arguments in tests and apply the
missing trailing comma required by the current lint rules.
2026-03-31 17:33:40 +08:00
248 changed files with 5353 additions and 1495 deletions

View file

@ -4,11 +4,29 @@ include: package:flutter_lints/flutter.yaml
# https://dart.dev/guides/language/analysis-options
linter:
rules:
- always_declare_return_types
- require_trailing_commas
- unnecessary_raw_strings
- prefer_collection_literals
- prefer_final_fields
- prefer_final_in_for_each
- prefer_final_locals
- sized_box_for_whitespace
- use_decorated_box
- unnecessary_parenthesis
- unnecessary_await_in_return
- unnecessary_raw_strings
- avoid_unnecessary_containers
- avoid_redundant_argument_values
- avoid_unused_constructor_parameters
- always_declare_return_types
- sort_constructors_first
- unawaited_futures
analyzer:
exclude:
- lib/src/l10n/**

View file

@ -0,0 +1,33 @@
import 'package:example/remote_scroll_repro.dart' as app;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('remote document updates do not move the local viewport',
(tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('reproduce_remote_scroll')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump(const Duration(seconds: 2));
await tester.pump();
await tester.pump(const Duration(milliseconds: 900));
await tester.pump();
final status = tester.widget<Text>(
find.byKey(const ValueKey('remote_scroll_repro_status')),
);
expect(
status.data,
'No auto-scroll request was observed.',
reason: 'A remote transaction may transform the local selection, but '
'must not make this client follow that selection.',
);
});
}

View file

@ -16,7 +16,9 @@ import 'package:example/pages/editor.dart';
import 'package:example/pages/editor_list.dart';
import 'package:example/pages/fixed_toolbar_editor.dart';
import 'package:example/pages/focus_example_for_editor.dart';
import 'package:example/pages/inline_comment_editor.dart';
import 'package:example/pages/markdown_editor.dart';
import 'package:example/pages/ten_thousand_links_showcase.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -281,6 +283,24 @@ class _HomePageState extends State<HomePage> {
);
}),
_buildListTile(context, 'Inline Comment', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InlineCommentEditor(),
),
);
}),
_buildListTile(context, '10,000 Links (one link per paragraph)', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TenThousandLinksShowcase(),
),
);
}),
// Encoder Demo
_buildSeparator(context, 'Export To X Demo'),
_buildListTile(context, 'Export To JSON', () {
@ -415,53 +435,51 @@ class _HomePageState extends State<HomePage> {
);
}
} else {
// for desktop
final path = await FilePicker.platform.saveFile(
// for desktop: file_picker v13 writes the file itself.
final Uint8List bytes;
if (fileType == ExportFileType.pdf) {
final pdf = await PdfHTMLEncoder(
fontFallback: [
await PdfGoogleFonts.notoColorEmoji(),
await PdfGoogleFonts.notoColorEmojiRegular(),
],
).convert(result);
bytes = await pdf.save();
} else {
bytes = utf8.encode(result);
}
final uri = await FilePicker.saveFile(
fileName: 'document.${fileType.extension}',
bytes: bytes,
);
if (path != null) {
await File(path).writeAsString(result);
if (fileType == ExportFileType.pdf) {
final pdf = await PdfHTMLEncoder(
fontFallback: [
await PdfGoogleFonts.notoColorEmoji(),
await PdfGoogleFonts.notoColorEmojiRegular(),
],
).convert(result);
await File(path).writeAsBytes(await pdf.save());
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This document is saved to the $path'),
),
);
}
if (uri != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This document is saved to the ${uri.toFilePath()}'),
),
);
}
}
}
void _importFile(ExportFileType fileType) async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
final file = await FilePicker.pickFile(
allowedExtensions: [fileType.extension],
type: FileType.custom,
);
if (file == null) {
return;
}
var plainText = '';
if (!kIsWeb) {
final path = result?.files.single.path;
final path = file.path;
if (path == null) {
return;
}
plainText = await File(path).readAsString();
} else {
final bytes = result?.files.first.bytes;
if (bytes == null) {
return;
}
plainText = const Utf8Decoder().convert(bytes);
// file_picker v13 reads a file's content on demand.
plainText = const Utf8Decoder().convert(await file.readAsBytes());
}
var jsonString = '';

View file

@ -0,0 +1,193 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class InlineCommentEditor extends StatefulWidget {
const InlineCommentEditor({super.key});
@override
State<InlineCommentEditor> createState() => _InlineCommentEditorState();
}
class _InlineCommentEditorState extends State<InlineCommentEditor> {
late final EditorState editorState;
late final EditorScrollController editorScrollController;
late final InlineCommentController commentController;
final List<InlineComment> _comments = [];
int _commentCounter = 0;
@override
void initState() {
super.initState();
// Create a document with some example text
final document = Document(
root: pageNode(
children: [
headingNode(
level: 1,
delta: Delta()..insert('Inline Comment Demo'),
),
paragraphNode(
delta: Delta()
..insert(
'Select any text in this editor and click the comment button '
'in the floating toolbar to add a comment. '
'The commented text will be highlighted with a yellow background.',
),
),
paragraphNode(
delta: Delta()
..insert(
'You can also click on highlighted text to see which comment '
'is associated with it. The sidebar on the right shows all '
'comments with their positions aligned to the text.',
),
),
paragraphNode(
delta: Delta()
..insert(
'Try adding multiple comments to different parts of the text '
'and observe how the sidebar updates automatically.',
),
),
],
),
);
editorState = EditorState(document: document);
editorScrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: false,
);
commentController = InlineCommentController(
onCommentAdded: _onCommentAdded,
onCommentDeleted: _onCommentDeleted,
onCommentTapped: _onCommentTapped,
);
}
@override
void dispose() {
editorScrollController.dispose();
editorState.dispose();
commentController.dispose();
super.dispose();
}
Future<String?> _onCommentAdded(
String nodeId,
int startOffset,
int endOffset,
String? initialText,
) async {
// Show a dialog for the user to enter a comment
final content = await showDialog<String>(
context: context,
builder: (ctx) {
final textController = TextEditingController();
return AlertDialog(
title: const Text('Add Comment'),
content: TextField(
controller: textController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Enter your comment...',
),
maxLines: 3,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, textController.text),
child: const Text('Add'),
),
],
);
},
);
if (content == null || content.isEmpty) return null;
final commentId = 'cmt-${++_commentCounter}';
final comment = InlineComment(
id: commentId,
content: content,
authorName: 'Demo User',
createdAt: DateTime.now(),
);
setState(() => _comments.add(comment));
commentController.updateComments(_comments);
return commentId;
}
Future<void> _onCommentDeleted(String commentId) async {
setState(() => _comments.removeWhere((c) => c.id == commentId));
commentController.updateComments(_comments);
}
void _onCommentTapped(String commentId, BuildContext context) {
final comment = commentController.findById(commentId);
if (comment == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${comment.authorName}: ${comment.content}'),
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Inline Comment Demo')),
body: InlineCommentWidget(
editorState: editorState,
controller: commentController,
showSidebar: true,
sidebarWidth: 260,
child: FloatingToolbar(
items: [
paragraphItem,
...headingItems,
...markdownFormatItems,
quoteItem,
bulletedListItem,
numberedListItem,
linkItem,
buildTextColorItem(),
buildHighlightColorItem(),
buildCommentToolbarItem(commentController),
],
tooltipBuilder: (context, _, message, child) {
return Tooltip(
message: message,
preferBelow: false,
child: child,
);
},
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: TextDirection.ltr,
child: AppFlowyEditor(
editorState: editorState,
editorScrollController: editorScrollController,
editorStyle: EditorStyle.desktop(
padding: const EdgeInsets.symmetric(horizontal: 40),
textSpanDecorator: buildCommentTextSpanDecorator(
controller: commentController,
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,50 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/editor.dart';
import 'package:flutter/material.dart';
class TenThousandLinksShowcase extends StatefulWidget {
const TenThousandLinksShowcase({super.key});
@override
State<TenThousandLinksShowcase> createState() =>
_TenThousandLinksShowcaseState();
}
class _TenThousandLinksShowcaseState extends State<TenThousandLinksShowcase> {
late final Future<String> _jsonString = _buildLinksDocument();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('10,000 Links Showcase')),
body: Editor(
jsonString: _jsonString,
onEditorStateChange: (_) {},
),
);
}
Future<String> _buildLinksDocument() {
final document = Document(
root: pageNode(
children: List.generate(
10000,
(index) => paragraphNode(
delta: Delta()
..insert(
'Link ${index + 1}',
attributes: {
AppFlowyRichTextKeys.href:
'https://example.com/links/${index + 1}',
},
),
),
),
),
);
return Future.value(jsonEncode(document.toJson()));
}
}

View file

@ -0,0 +1,240 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() {
runApp(const RemoteScrollReproApp());
}
class RemoteScrollReproApp extends StatelessWidget {
const RemoteScrollReproApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
AppFlowyEditorLocalizations.delegate,
],
supportedLocales: AppFlowyEditorLocalizations.delegate.supportedLocales,
home: const RemoteScrollReproPage(),
);
}
}
class RemoteScrollReproPage extends StatefulWidget {
const RemoteScrollReproPage({super.key});
@override
State<RemoteScrollReproPage> createState() => _RemoteScrollReproPageState();
}
class _RemoteScrollReproPageState extends State<RemoteScrollReproPage> {
static const _localCursorIndex = 8;
static const _viewportIndex = 14;
static const _remoteEditIndex = 30;
late final EditorState _editorState;
late final EditorScrollController _editorScrollController;
bool _isRunning = false;
int _remoteEditCount = 0;
String _status = 'Press “Reproduce” to run the two-client sequence.';
double? _offsetBeforeRemoteEdit;
double? _offsetAfterRemoteEdit;
(int, int)? _visibleRangeBeforeRemoteEdit;
(int, int)? _visibleRangeAfterRemoteEdit;
Offset? _autoScrollTarget;
@override
void initState() {
super.initState();
final nodes = List.generate(
80,
(index) => paragraphNode(
text: switch (index) {
_localCursorIndex =>
'LOCAL CURSOR — this position belongs to Client A',
_remoteEditIndex =>
'REMOTE EDIT TARGET — Client B will edit this paragraph',
_ => 'Paragraph $index — collaborative document content',
},
),
);
_editorState = EditorState(
document: Document(root: pageNode(children: nodes)),
);
_editorScrollController = EditorScrollController(
editorState: _editorState,
);
}
@override
void dispose() {
_editorScrollController.dispose();
_editorState.dispose();
super.dispose();
}
Future<void> _reproduce() async {
if (_isRunning) {
return;
}
setState(() {
_isRunning = true;
_offsetBeforeRemoteEdit = null;
_offsetAfterRemoteEdit = null;
_visibleRangeBeforeRemoteEdit = null;
_visibleRangeAfterRemoteEdit = null;
_autoScrollTarget = null;
_status = 'Client A: placing the local cursor at paragraph 8…';
});
await _editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: const [_localCursorIndex])),
reason: SelectionUpdateReason.uiEvent,
);
await Future<void>.delayed(const Duration(milliseconds: 350));
_editorScrollController.itemScrollController.jumpTo(
index: _viewportIndex,
alignment: 0,
);
await Future<void>.delayed(const Duration(milliseconds: 700));
_editorState.autoScroller?.stopAutoScroll();
_offsetBeforeRemoteEdit = _editorScrollController.offsetNotifier.value;
_visibleRangeBeforeRemoteEdit =
_editorScrollController.visibleRangeNotifier.value;
if (mounted) {
setState(() {
_status = 'Client A: viewport moved away from its cursor. '
'Client B edits in 2 seconds…';
});
}
await Future<void>.delayed(const Duration(seconds: 2));
final remotelyEditedNode =
_editorState.document.nodeAtPath(const [_remoteEditIndex]);
if (remotelyEditedNode == null) {
if (mounted) {
setState(() {
_isRunning = false;
_status = 'Unable to find the remote edit target.';
});
}
return;
}
_remoteEditCount++;
final transaction = _editorState.transaction
..updateNode(
remotelyEditedNode,
{
ParagraphBlockKeys.delta: (Delta()
..insert(
'REMOTE EDIT #$_remoteEditCount — received from Client B',
))
.toJson(),
},
);
await _editorState.apply(transaction, isRemote: true);
await Future<void>.delayed(const Duration(milliseconds: 900));
_offsetAfterRemoteEdit = _editorScrollController.offsetNotifier.value;
_visibleRangeAfterRemoteEdit =
_editorScrollController.visibleRangeNotifier.value;
_autoScrollTarget = _editorState.autoScroller?.lastOffset;
if (!mounted) {
return;
}
setState(() {
_isRunning = false;
_status = _autoScrollTarget == null
? 'No auto-scroll request was observed.'
: 'BUG REPRODUCED: the remote edit requested local auto-scroll.';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Remote edit viewport-jump reproducer'),
backgroundColor: const Color(0xFF5E2CA5),
foregroundColor: Colors.white,
),
body: Column(
children: [
Material(
color: const Color(0xFFF1EAFE),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilledButton.icon(
key: const ValueKey('reproduce_remote_scroll'),
onPressed: _isRunning ? null : _reproduce,
icon: const Icon(Icons.play_arrow),
label: Text(_isRunning ? 'Running…' : 'Reproduce'),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
key: const ValueKey('remote_scroll_repro_status'),
_status,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'Visible blocks before: '
'${_formatRange(_visibleRangeBeforeRemoteEdit)} • '
'after: '
'${_formatRange(_visibleRangeAfterRemoteEdit)} • '
'scroll movement: ${_formatMovement()} • '
'auto-scroll target: '
'${_autoScrollTarget ?? 'none'}',
),
],
),
),
],
),
),
),
const Divider(height: 1),
Expanded(
child: AppFlowyEditor(
editorState: _editorState,
editorScrollController: _editorScrollController,
editorStyle: const EditorStyle.desktop(
padding: EdgeInsets.symmetric(horizontal: 80),
),
),
),
],
),
);
}
String _formatRange((int, int)? range) =>
range == null ? '—' : '${range.$1}–${range.$2}';
String _formatMovement() {
final before = _offsetBeforeRemoteEdit;
final after = _offsetAfterRemoteEdit;
if (before == null || after == null) {
return '—';
}
return '${(after - before).toStringAsFixed(1)} px';
}
}

View file

@ -42,7 +42,7 @@ dependencies:
google_fonts: ^6.1.0
flutter_localizations:
sdk: flutter
file_picker: ^10.3.10
file_picker: ^13.1.0
universal_html: ^2.0.8
highlight: ^0.7.0
http: ^1.1.0
@ -65,6 +65,8 @@ dependency_overrides:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is

View file

@ -9,6 +9,17 @@ export 'src/editor/block_component/rich_text/default_selectable_mixin.dart';
export 'src/editor/block_component/table_block_component/table.dart';
// editor part, including editor component, block component, etc.
export 'src/editor/editor.dart';
// Inline Comment
export 'src/editor/inline_comment/inline_comment.dart';
export 'src/editor/inline_comment/inline_comment_controller.dart';
export 'src/editor/inline_comment/inline_comment_service.dart'
show CommentAnchor;
export 'src/editor/inline_comment/comment_sidebar_widget.dart';
export 'src/editor/inline_comment/inline_comment_widget.dart';
export 'src/editor/inline_comment/comment_toolbar_item.dart';
export 'src/editor/inline_comment/comment_mobile_toolbar_item.dart';
export 'src/editor/inline_comment/comment_text_span_decorator.dart'
show buildCommentTextSpanDecorator;
export 'src/editor/find_replace_menu/find_and_replace.dart';
export 'src/editor/l10n/appflowy_editor_l10n.dart';
export 'src/editor/selection_menu/selection_menu.dart';

View file

@ -0,0 +1,317 @@
import 'dart:collection';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
import 'package:flutter/material.dart';
///
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
///
/// ONLY USE FOR MIGRATION.
///
final class NodeV0 extends ChangeNotifier with LinkedListEntry<NodeV0> {
NodeV0({
required this.type,
Attributes? attributes,
this.parent,
LinkedList<NodeV0>? children,
}) : children = children ?? LinkedList<NodeV0>(),
_attributes = attributes ?? {} {
for (final child in this.children) {
child.parent = this;
}
}
factory NodeV0.fromJson(Map<String, Object> json) {
assert(json['type'] is String);
final jType = json['type'] as String;
final jChildren = json['children'] as List?;
final jAttributes = json['attributes'] != null
? Attributes.from(json['attributes'] as Map)
: Attributes.from({});
final children = LinkedList<NodeV0>();
if (jChildren != null) {
children.addAll(
jChildren.map(
(jChild) => NodeV0.fromJson(
Map<String, Object>.from(jChild),
),
),
);
}
NodeV0 node;
if (jType == 'text') {
final jDelta = json['delta'] as List<dynamic>?;
final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta);
node = TextNodeV0(
children: children,
attributes: jAttributes,
delta: delta,
);
} else {
node = NodeV0(
type: jType,
children: children,
attributes: jAttributes,
);
}
for (final child in children) {
child.parent = node;
}
return node;
}
final String type;
final LinkedList<NodeV0> children;
NodeV0? parent;
Attributes _attributes;
// Renderable
final key = GlobalKey();
final layerLink = LayerLink();
Attributes get attributes => {..._attributes};
String get id {
if (subtype != null) {
return '$type/$subtype';
}
return type;
}
String? get subtype {
if (attributes[BuiltInAttributeKey.subtype] is String) {
return attributes[BuiltInAttributeKey.subtype] as String;
}
return null;
}
Path get path => _computePath();
void updateAttributes(Attributes attributes) {
final oldAttributes = this.attributes;
_attributes = composeAttributes(this.attributes, attributes) ?? {};
// Notifies the new attributes
// if attributes contains 'subtype', should notify parent to rebuild node
// else, just notify current node.
final bool shouldNotifyParent =
this.attributes['subtype'] != oldAttributes['subtype'];
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
}
NodeV0? childAtIndex(int index) {
if (children.length <= index || index < 0) {
return null;
}
return children.elementAt(index);
}
NodeV0? childAtPath(Path path) {
if (path.isEmpty) {
return this;
}
return childAtIndex(path.first)?.childAtPath(path.sublist(1));
}
void insert(NodeV0 entry, {int? index}) {
final length = children.length;
index ??= length;
if (children.isEmpty) {
entry.parent = this;
children.add(entry);
notifyListeners();
return;
}
// If index is out of range, insert at the end.
// If index is negative, insert at the beginning.
// If index is positive, insert at the index.
if (index >= length) {
children.last.insertAfter(entry);
} else if (index <= 0) {
children.first.insertBefore(entry);
} else {
childAtIndex(index)?.insertBefore(entry);
}
}
@override
void insertAfter(NodeV0 entry) {
entry.parent = parent;
super.insertAfter(entry);
// Notifies the new node.
parent?.notifyListeners();
}
@override
void insertBefore(NodeV0 entry) {
entry.parent = parent;
super.insertBefore(entry);
// Notifies the new node.
parent?.notifyListeners();
}
@override
void unlink() {
super.unlink();
parent?.notifyListeners();
parent = null;
}
Map<String, Object> toJson() {
final map = <String, Object>{
'type': type,
};
if (children.isNotEmpty) {
map['children'] =
children.map((node) => node.toJson()).toList(growable: false);
}
if (attributes.isNotEmpty) {
map['attributes'] = attributes;
}
return map;
}
NodeV0 copyWith({
String? type,
LinkedList<NodeV0>? children,
Attributes? attributes,
}) {
final node = NodeV0(
type: type ?? this.type,
attributes: attributes ?? {...this.attributes},
children: children,
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
node.children.add(
child.copyWith()..parent = node,
);
}
}
return node;
}
Path _computePath([Path previous = const []]) {
if (parent == null) {
return previous;
}
var index = 0;
for (final child in parent!.children) {
if (child == this) {
break;
}
index += 1;
}
return parent!._computePath([index, ...previous]);
}
}
final class TextNodeV0 extends NodeV0 {
TextNodeV0({
required Delta delta,
super.children,
Attributes? attributes,
}) : _delta = delta,
super(
type: 'text',
attributes: attributes ?? {},
);
TextNodeV0.empty({Attributes? attributes})
: _delta = Delta(operations: [TextInsert('')]),
super(
type: 'text',
attributes: attributes ?? {},
);
Delta _delta;
Delta get delta => _delta;
set delta(Delta v) {
_delta = v;
notifyListeners();
}
@override
Map<String, Object> toJson() {
final map = super.toJson();
map['delta'] = delta.toJson();
return map;
}
@override
TextNodeV0 copyWith({
String? type = 'text',
LinkedList<NodeV0>? children,
Attributes? attributes,
Delta? delta,
}) {
final textNode = TextNodeV0(
children: children,
attributes: attributes ?? this.attributes,
delta: delta ?? this.delta,
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
textNode.children.add(
child.copyWith()..parent = textNode,
);
}
}
return textNode;
}
String toPlainText() => _delta.toPlainText();
}
extension NodeV0Equality on Iterable<NodeV0> {
bool equals(Iterable<NodeV0> other) {
if (length != other.length) {
return false;
}
for (var i = 0; i < length; i++) {
if (!_nodeEquals(elementAt(i), other.elementAt(i))) {
return false;
}
}
return true;
}
bool _nodeEquals<T, U>(T base, U other) {
if (identical(this, other)) return true;
return base is NodeV0 &&
other is NodeV0 &&
other.type == base.type &&
other.children.equals(base.children);
}
}

View file

@ -8,9 +8,20 @@ List<Operation> diffDocuments(Document oldDocument, Document newDocument) {
}
List<Operation> diffNodes(Node oldNode, Node newNode) {
List<Operation> operations = [];
final List<Operation> operations = [];
if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
if (oldNode.type != newNode.type && oldNode.path.isNotEmpty) {
operations.add(
UpdateNodeTypeOperation(
oldNode.path,
oldNode.id,
newNode.type,
oldNode.type,
newNode.attributes,
oldNode.attributes,
),
);
} else if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
operations.add(
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
);

View file

@ -153,6 +153,35 @@ class Document {
return true;
}
/// Updates the [Node] type at the given [Path] while preserving its id,
/// children, external values, and temporary metadata.
bool updateNodeType(Path path, String type, Attributes attributes) {
if (path.isEmpty) {
return false;
}
final target = nodeAtPath(path);
final parent = target?.parent;
if (target == null || parent == null) {
return false;
}
final index = path.last;
final replacement = Node(
type: type,
id: target.id,
attributes: {...attributes},
children: target.children.toList(growable: false),
)
..externalValues = target.externalValues
..extraInfos = target.extraInfos;
target.unlink();
parent.insert(replacement, index: index);
return true;
}
/// Updates the [Node] with [Delta] at the given [Path]
bool updateText(Path path, Delta delta) {
if (path.isEmpty) {
@ -163,7 +192,8 @@ class Document {
if (target == null || targetDelta == null) {
return false;
}
target.updateAttributes({'delta': (targetDelta.compose(delta)).toJson()});
target.updateAttributes({'delta': targetDelta.compose(delta).toJson()});
return true;
}
@ -196,4 +226,8 @@ class Document {
'document': root.toJson(),
};
}
Document deepCopy() {
return Document.fromJson(toJson());
}
}

View file

@ -271,19 +271,24 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
/// Be careful of the children, they will be deep copied if not provided.
Node copyWith({
String? type,
String? id,
bool preserveChildIds = false,
Iterable<Node>? children,
Attributes? attributes,
}) {
final node = Node(
type: type ?? this.type,
id: nanoid(6),
id: id ?? nanoid(6),
attributes: attributes ?? {...this.attributes},
children: children ?? [],
);
if (children == null && _children.isNotEmpty) {
for (final child in _children) {
node._children.add(
child.copyWith()..parent = node,
child.copyWith(
id: preserveChildIds ? child.id : null,
preserveChildIds: preserveChildIds,
)..parent = node,
);
}
}
@ -385,16 +390,23 @@ final class TextNode extends Node {
Attributes? attributes,
Delta? delta,
String? id,
bool preserveChildIds = false,
}) {
final textNode = TextNode(
children: children ?? [],
attributes: attributes ?? this.attributes,
delta: delta ?? this.delta,
);
if (id != null) {
textNode.id = id;
}
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
textNode._children.add(
child.copyWith()..parent = textNode,
child.copyWith(
id: preserveChildIds ? child.id : null,
preserveChildIds: preserveChildIds,
)..parent = textNode,
);
}
}

View file

@ -65,7 +65,7 @@ extension PathExtensions on Path {
}
Path get next {
Path nextPath = Path.from(this, growable: true);
final Path nextPath = Path.from(this);
if (isEmpty) {
return nextPath;
}
@ -77,7 +77,7 @@ extension PathExtensions on Path {
}
Path nextNPath(int n) {
Path nextPath = Path.from(this, growable: true);
final Path nextPath = Path.from(this);
if (isEmpty) {
return nextPath;
}
@ -89,11 +89,11 @@ extension PathExtensions on Path {
}
Path child(int index) {
return Path.from(this, growable: true)..add(index);
return Path.from(this)..add(index);
}
Path get previous {
Path previousPath = Path.from(this, growable: true);
final Path previousPath = Path.from(this);
if (isEmpty) {
return previousPath;
}
@ -105,7 +105,7 @@ extension PathExtensions on Path {
}
Path previousNPath(int n) {
Path previousPath = Path.from(this, growable: true);
final Path previousPath = Path.from(this);
if (isEmpty) {
return previousPath;
}
@ -121,7 +121,7 @@ extension PathExtensions on Path {
return this;
}
return Path.from(this, growable: true)..removeLast();
return Path.from(this)..removeLast();
}
bool isAncestorOf(Path other) {
@ -149,7 +149,7 @@ extension PathExtensions on Path {
bool isSameDepth = false,
}) {
selection = selection?.normalized;
bool result = selection != null &&
final bool result = selection != null &&
selection.start.path <= this &&
this <= selection.end.path;
if (isSameDepth) {

View file

@ -31,6 +31,6 @@ class AtLeastOneEditableNodeRule extends DocumentRule {
..afterSelection = Selection.collapsed(
Position(path: [0]),
);
editorState.apply(transaction);
await editorState.apply(transaction);
}
}

View file

@ -487,7 +487,7 @@ class Delta extends Iterable<TextOperation> {
/// This method joins two Delta together.
Delta operator +(Delta other) {
var operations = [..._operations];
final operations = [..._operations];
if (other._operations.isNotEmpty) {
operations.add(other._operations[0]);
operations.addAll(other._operations.sublist(1));

View file

@ -1,9 +1,6 @@
import 'package:appflowy_editor/src/core/document/path.dart';
class Position {
final Path path;
final int offset;
Position({
required this.path,
this.offset = 0,
@ -22,6 +19,8 @@ class Position {
offset: offset ?? 0,
);
}
final Path path;
final int offset;
@override
bool operator ==(Object other) {

View file

@ -182,6 +182,101 @@ class UpdateOperation extends Operation {
path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode;
}
/// [UpdateNodeTypeOperation] changes a node's block type without replacing
/// the node in the document tree.
class UpdateNodeTypeOperation extends Operation {
const UpdateNodeTypeOperation(
super.path,
this.nodeId,
this.type,
this.oldType,
this.attributes,
this.oldAttributes,
);
factory UpdateNodeTypeOperation.fromJson(Map<String, dynamic> json) {
final path = json['path'] as Path;
final nodeId = json['nodeId'] as String? ?? '';
final type = json['type'] as String;
final oldType = json['oldType'] as String;
final attributes = json['attributes'] as Attributes;
final oldAttributes = json['oldAttributes'] as Attributes;
return UpdateNodeTypeOperation(
path,
nodeId,
type,
oldType,
attributes,
oldAttributes,
);
}
final String nodeId;
final String type;
final String oldType;
final Attributes attributes;
final Attributes oldAttributes;
@override
Operation invert() => UpdateNodeTypeOperation(
path,
nodeId,
oldType,
type,
oldAttributes,
attributes,
);
@override
Map<String, dynamic> toJson() {
return {
'op': 'update_node_type',
'path': path,
'nodeId': nodeId,
'type': type,
'oldType': oldType,
'attributes': {...attributes},
'oldAttributes': {...oldAttributes},
};
}
@override
Operation copyWith({Path? path}) {
return UpdateNodeTypeOperation(
path ?? this.path,
nodeId,
type,
oldType,
{...attributes},
{...oldAttributes},
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UpdateNodeTypeOperation &&
other.path.equals(path) &&
other.nodeId == nodeId &&
other.type == type &&
other.oldType == oldType &&
mapEquals(other.attributes, attributes) &&
mapEquals(other.oldAttributes, oldAttributes);
}
@override
int get hashCode => Object.hash(
Object.hashAll(path),
nodeId,
type,
oldType,
hashAttributes(attributes),
hashAttributes(oldAttributes),
);
}
/// [UpdateTextOperation] represents a text update operation.
class UpdateTextOperation extends Operation {
const UpdateTextOperation(

View file

@ -92,13 +92,35 @@ class Transaction {
);
}
/// Updates a node's type while keeping the same node id and children.
///
/// Unlike [updateNode], this replaces the node attributes. Block types have
/// different data contracts, so carrying old type-specific attributes into
/// the new block type would leak stale state.
void updateNodeType(
Node node,
String type,
Attributes attributes,
) {
add(
UpdateNodeTypeOperation(
node.path,
node.id,
type,
node.type,
{...attributes},
{...node.attributes},
),
);
}
/// Deletes the [Node] in the document.
void deleteNode(Node node) {
deleteNodesAtPath(node.path);
if (beforeSelection != null) {
final nodePath = node.path;
final selectionPath = beforeSelection!.start.path;
if (!(nodePath.equals(selectionPath))) {
if (!nodePath.equals(selectionPath)) {
afterSelection = beforeSelection;
}
}

View file

@ -80,7 +80,6 @@ class _BlockComponentActionWrapperState
opaque: false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ValueListenableBuilder<bool>(

View file

@ -10,6 +10,7 @@ typedef BlockComponentTextStyleBuilder = TextStyle Function(
class BlockComponentConfiguration {
const BlockComponentConfiguration({
this.padding = _padding,
this.margin = _margin,
this.indentPadding = _indentPadding,
this.placeholderText = _placeholderText,
this.textStyle = _textStyle,
@ -23,6 +24,11 @@ class BlockComponentConfiguration {
/// It works only for the block component itself, not for the children.
final EdgeInsets Function(Node node) padding;
/// The margin of a block component.
///
/// Sits outside the padding/background/selection area.
final EdgeInsets Function(Node node) margin;
/// The padding of a block component.
///
/// It works only for the block that needs to be indented.
@ -53,6 +59,7 @@ class BlockComponentConfiguration {
BlockComponentConfiguration copyWith({
EdgeInsets Function(Node node)? padding,
EdgeInsets Function(Node node)? margin,
BlockComponentTextStyleBuilder? textStyle,
String Function(Node node)? placeholderText,
BlockComponentTextStyleBuilder? placeholderTextStyle,
@ -62,6 +69,7 @@ class BlockComponentConfiguration {
}) {
return BlockComponentConfiguration(
padding: padding ?? this.padding,
margin: margin ?? this.margin,
textStyle: textStyle ?? this.textStyle,
placeholderText: placeholderText ?? this.placeholderText,
placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle,
@ -78,6 +86,7 @@ mixin BlockComponentConfigurable<T extends StatefulWidget> on State<T> {
Node get node;
EdgeInsets get padding => configuration.padding(node);
EdgeInsets get margin => configuration.margin(node);
TextStyle textStyleWithTextSpan({TextSpan? textSpan}) =>
configuration.textStyle(node, textSpan: textSpan);
@ -94,6 +103,10 @@ EdgeInsets _padding(Node node) {
return const EdgeInsets.symmetric(vertical: 4.0);
}
EdgeInsets _margin(Node node) {
return EdgeInsets.zero;
}
EdgeInsets _indentPadding(Node node, TextDirection textDirection) {
switch (textDirection) {
case TextDirection.ltr:
@ -119,7 +132,7 @@ TextStyle _placeholderTextStyle(Node node, {TextSpan? textSpan}) {
}
EdgeInsets _blockSelectionAreaPadding(Node node) {
return const EdgeInsets.symmetric(vertical: 0.0);
return const EdgeInsets.symmetric();
}
TextAlign _textAlign(Node node) {

Some files were not shown because too many files have changed in this diff Show more