appflowy-editor/example/lib/remote_scroll_repro.dart

240 lines
7.5 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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