Compare commits
248 changed files with 1495 additions and 5353 deletions
|
|
@ -4,28 +4,10 @@ include: package:flutter_lints/flutter.yaml
|
|||
# https://dart.dev/guides/language/analysis-options
|
||||
linter:
|
||||
rules:
|
||||
- require_trailing_commas
|
||||
|
||||
- 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
|
||||
- require_trailing_commas
|
||||
- unnecessary_raw_strings
|
||||
- use_decorated_box
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
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.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -16,9 +16,7 @@ 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';
|
||||
|
|
@ -283,24 +281,6 @@ 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', () {
|
||||
|
|
@ -435,51 +415,53 @@ class _HomePageState extends State<HomePage> {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
// 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(
|
||||
// for desktop
|
||||
final path = await FilePicker.platform.saveFile(
|
||||
fileName: 'document.${fileType.extension}',
|
||||
bytes: bytes,
|
||||
);
|
||||
if (uri != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('This document is saved to the ${uri.toFilePath()}'),
|
||||
),
|
||||
);
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _importFile(ExportFileType fileType) async {
|
||||
final file = await FilePicker.pickFile(
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: false,
|
||||
allowedExtensions: [fileType.extension],
|
||||
type: FileType.custom,
|
||||
);
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
var plainText = '';
|
||||
if (!kIsWeb) {
|
||||
final path = file.path;
|
||||
final path = result?.files.single.path;
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
plainText = await File(path).readAsString();
|
||||
} else {
|
||||
// file_picker v13 reads a file's content on demand.
|
||||
plainText = const Utf8Decoder().convert(await file.readAsBytes());
|
||||
final bytes = result?.files.first.bytes;
|
||||
if (bytes == null) {
|
||||
return;
|
||||
}
|
||||
plainText = const Utf8Decoder().convert(bytes);
|
||||
}
|
||||
|
||||
var jsonString = '';
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ dependencies:
|
|||
google_fonts: ^6.1.0
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
file_picker: ^13.1.0
|
||||
file_picker: ^10.3.10
|
||||
universal_html: ^2.0.8
|
||||
highlight: ^0.7.0
|
||||
http: ^1.1.0
|
||||
|
|
@ -65,8 +65,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -9,17 +9,6 @@ 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';
|
||||
|
|
|
|||
|
|
@ -1,317 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,20 +8,9 @@ List<Operation> diffDocuments(Document oldDocument, Document newDocument) {
|
|||
}
|
||||
|
||||
List<Operation> diffNodes(Node oldNode, Node newNode) {
|
||||
final List<Operation> operations = [];
|
||||
List<Operation> operations = [];
|
||||
|
||||
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)) {
|
||||
if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
|
||||
operations.add(
|
||||
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -153,35 +153,6 @@ 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) {
|
||||
|
|
@ -192,8 +163,7 @@ 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;
|
||||
}
|
||||
|
|
@ -226,8 +196,4 @@ class Document {
|
|||
'document': root.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
Document deepCopy() {
|
||||
return Document.fromJson(toJson());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,24 +271,19 @@ 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: id ?? nanoid(6),
|
||||
id: nanoid(6),
|
||||
attributes: attributes ?? {...this.attributes},
|
||||
children: children ?? [],
|
||||
);
|
||||
if (children == null && _children.isNotEmpty) {
|
||||
for (final child in _children) {
|
||||
node._children.add(
|
||||
child.copyWith(
|
||||
id: preserveChildIds ? child.id : null,
|
||||
preserveChildIds: preserveChildIds,
|
||||
)..parent = node,
|
||||
child.copyWith()..parent = node,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -390,23 +385,16 @@ 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(
|
||||
id: preserveChildIds ? child.id : null,
|
||||
preserveChildIds: preserveChildIds,
|
||||
)..parent = textNode,
|
||||
child.copyWith()..parent = textNode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ extension PathExtensions on Path {
|
|||
}
|
||||
|
||||
Path get next {
|
||||
final Path nextPath = Path.from(this);
|
||||
Path nextPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
return nextPath;
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ extension PathExtensions on Path {
|
|||
}
|
||||
|
||||
Path nextNPath(int n) {
|
||||
final Path nextPath = Path.from(this);
|
||||
Path nextPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
return nextPath;
|
||||
}
|
||||
|
|
@ -89,11 +89,11 @@ extension PathExtensions on Path {
|
|||
}
|
||||
|
||||
Path child(int index) {
|
||||
return Path.from(this)..add(index);
|
||||
return Path.from(this, growable: true)..add(index);
|
||||
}
|
||||
|
||||
Path get previous {
|
||||
final Path previousPath = Path.from(this);
|
||||
Path previousPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
return previousPath;
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ extension PathExtensions on Path {
|
|||
}
|
||||
|
||||
Path previousNPath(int n) {
|
||||
final Path previousPath = Path.from(this);
|
||||
Path previousPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
return previousPath;
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ extension PathExtensions on Path {
|
|||
return this;
|
||||
}
|
||||
|
||||
return Path.from(this)..removeLast();
|
||||
return Path.from(this, growable: true)..removeLast();
|
||||
}
|
||||
|
||||
bool isAncestorOf(Path other) {
|
||||
|
|
@ -149,7 +149,7 @@ extension PathExtensions on Path {
|
|||
bool isSameDepth = false,
|
||||
}) {
|
||||
selection = selection?.normalized;
|
||||
final bool result = selection != null &&
|
||||
bool result = selection != null &&
|
||||
selection.start.path <= this &&
|
||||
this <= selection.end.path;
|
||||
if (isSameDepth) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@ class AtLeastOneEditableNodeRule extends DocumentRule {
|
|||
..afterSelection = Selection.collapsed(
|
||||
Position(path: [0]),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,7 +487,7 @@ class Delta extends Iterable<TextOperation> {
|
|||
|
||||
/// This method joins two Delta together.
|
||||
Delta operator +(Delta other) {
|
||||
final operations = [..._operations];
|
||||
var operations = [..._operations];
|
||||
if (other._operations.isNotEmpty) {
|
||||
operations.add(other._operations[0]);
|
||||
operations.addAll(other._operations.sublist(1));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||
|
||||
class Position {
|
||||
final Path path;
|
||||
final int offset;
|
||||
|
||||
Position({
|
||||
required this.path,
|
||||
this.offset = 0,
|
||||
|
|
@ -19,8 +22,6 @@ class Position {
|
|||
offset: offset ?? 0,
|
||||
);
|
||||
}
|
||||
final Path path;
|
||||
final int offset;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
|
|
|
|||
|
|
@ -182,101 +182,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -92,35 +92,13 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class _BlockComponentActionWrapperState
|
|||
opaque: false,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ValueListenableBuilder<bool>(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ typedef BlockComponentTextStyleBuilder = TextStyle Function(
|
|||
class BlockComponentConfiguration {
|
||||
const BlockComponentConfiguration({
|
||||
this.padding = _padding,
|
||||
this.margin = _margin,
|
||||
this.indentPadding = _indentPadding,
|
||||
this.placeholderText = _placeholderText,
|
||||
this.textStyle = _textStyle,
|
||||
|
|
@ -24,11 +23,6 @@ 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.
|
||||
|
|
@ -59,7 +53,6 @@ class BlockComponentConfiguration {
|
|||
|
||||
BlockComponentConfiguration copyWith({
|
||||
EdgeInsets Function(Node node)? padding,
|
||||
EdgeInsets Function(Node node)? margin,
|
||||
BlockComponentTextStyleBuilder? textStyle,
|
||||
String Function(Node node)? placeholderText,
|
||||
BlockComponentTextStyleBuilder? placeholderTextStyle,
|
||||
|
|
@ -69,7 +62,6 @@ class BlockComponentConfiguration {
|
|||
}) {
|
||||
return BlockComponentConfiguration(
|
||||
padding: padding ?? this.padding,
|
||||
margin: margin ?? this.margin,
|
||||
textStyle: textStyle ?? this.textStyle,
|
||||
placeholderText: placeholderText ?? this.placeholderText,
|
||||
placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle,
|
||||
|
|
@ -86,7 +78,6 @@ 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);
|
||||
|
|
@ -103,10 +94,6 @@ 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:
|
||||
|
|
@ -132,7 +119,7 @@ TextStyle _placeholderTextStyle(Node node, {TextSpan? textSpan}) {
|
|||
}
|
||||
|
||||
EdgeInsets _blockSelectionAreaPadding(Node node) {
|
||||
return const EdgeInsets.symmetric();
|
||||
return const EdgeInsets.symmetric(vertical: 0.0);
|
||||
}
|
||||
|
||||
TextAlign _textAlign(Node node) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue