feat: support remote selections (#753)

* chore: delete deprecated code

* feat: support remote seleciton

* feat: support selection flag
This commit is contained in:
Lucas.Xu 2024-03-27 17:01:34 +08:00 • committed by GitHub
commit b927ec0685
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 424 additions and 210 deletions

View file

@ -5,6 +5,7 @@ import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/collab_editor.dart'; import 'package:example/pages/collab_editor.dart';
import 'package:example/pages/collab_selection_editor.dart';
import 'package:example/pages/customize_theme_for_editor.dart'; import 'package:example/pages/customize_theme_for_editor.dart';
import 'package:example/pages/editor.dart'; import 'package:example/pages/editor.dart';
import 'package:example/pages/editor_list.dart'; import 'package:example/pages/editor_list.dart';
@ -168,6 +169,14 @@ class _HomePageState extends State<HomePage> {
), ),
); );
}), }),
_buildListTile(context, 'Collab Selection', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CollabSelectionEditor(),
),
);
}),
_buildListTile(context, 'Custom Theme', () { _buildListTile(context, 'Custom Theme', () {
Navigator.push( Navigator.push(
context, context,

View file

@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CollabSelectionEditor extends StatefulWidget {
const CollabSelectionEditor({super.key});
@override
State<CollabSelectionEditor> createState() => _CollabSelectionEditorState();
}
class _CollabSelectionEditorState extends State<CollabSelectionEditor> {
late final Future<String> future;
EditorState? editorState;
late final List<RemoteSelection> remoteSelections = [
RemoteSelection(
id: '1',
selection: Selection(
start: Position(path: [0], offset: 0),
end: Position(path: [0], offset: 5),
),
selectionColor: Colors.red,
cursorColor: Colors.red,
builder: _buildSelectionFlag,
),
// collapsed selection
RemoteSelection(
id: '2',
selection: Selection.collapsed(
Position(path: [1], offset: 3),
),
selectionColor: Colors.yellow,
cursorColor: Colors.yellow,
builder: _buildSelectionFlag,
),
// multi-line selection
RemoteSelection(
id: '3',
selection: Selection(
start: Position(path: [2], offset: 0),
end: Position(path: [3], offset: 5),
),
selectionColor: Colors.green,
cursorColor: Colors.green,
builder: _buildSelectionFlag,
),
];
@override
void initState() {
super.initState();
future = PlatformExtension.isDesktopOrWeb
? rootBundle.loadString('assets/example.json')
: rootBundle.loadString('assets/mobile_example.json').then((value) {
return value;
});
}
@override
void dispose() {
editorState?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: FutureBuilder<String>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(),
);
}
editorState?.dispose();
editorState ??= EditorState(
document: Document.fromJson(jsonDecode(snapshot.data!)),
);
return AppFlowyEditor(
editorState: editorState!,
);
},
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
editorState?.remoteSelections.value = [...remoteSelections];
},
child: const Text('Add Selection'),
),
],
),
);
}
Widget _buildSelectionFlag(
BuildContext context,
RemoteSelection remoteSelection,
Rect rect,
) {
final selection = remoteSelection.selection;
if (selection.isCollapsed) {
return Positioned(
top: rect.top - 5.0,
left: rect.right,
child: const Text(
'Lucas.Xu',
style: TextStyle(
backgroundColor: Colors.yellow,
color: Colors.black,
fontSize: 10,
),
),
);
}
return Positioned(
top: rect.top - 5.0,
left: rect.left,
child: const Text(
'Guest',
style: TextStyle(
backgroundColor: Colors.yellow,
color: Colors.black,
fontSize: 10,
),
),
);
}
}

View file

@ -8,6 +8,7 @@ class BlockSelectionContainer extends StatelessWidget {
required this.node, required this.node,
required this.delegate, required this.delegate,
required this.listenable, required this.listenable,
this.remoteSelection,
this.cursorColor = Colors.black, this.cursorColor = Colors.black,
this.selectionColor = Colors.blue, this.selectionColor = Colors.blue,
this.blockColor = Colors.blue, this.blockColor = Colors.blue,
@ -24,6 +25,9 @@ class BlockSelectionContainer extends StatelessWidget {
// get the selection from the listenable // get the selection from the listenable
final ValueListenable<Selection?> listenable; final ValueListenable<Selection?> listenable;
// remote selection
final ValueListenable<List<RemoteSelection>>? remoteSelection;
// the color of the cursor // the color of the cursor
final Color cursorColor; final Color cursorColor;
@ -50,6 +54,17 @@ class BlockSelectionContainer extends StatelessWidget {
? AlignmentDirectional.topStart ? AlignmentDirectional.topStart
: AlignmentDirectional.topEnd, : AlignmentDirectional.topEnd,
children: [ children: [
if (remoteSelection != null)
RemoteBlockSelectionsArea(
node: node,
delegate: delegate,
remoteSelections: remoteSelection!,
supportTypes: supportTypes
.where(
(element) => element != BlockSelectionType.cursor,
)
.toList(),
),
// block selection or selection area // block selection or selection area
BlockSelectionArea( BlockSelectionArea(
node: node, node: node,
@ -66,6 +81,16 @@ class BlockSelectionContainer extends StatelessWidget {
), ),
child, child,
// cursor // cursor
// remote cursor
if (supportTypes.contains(BlockSelectionType.cursor) &&
remoteSelection != null)
RemoteBlockSelectionsArea(
node: node,
delegate: delegate,
remoteSelections: remoteSelection!,
supportTypes: const [BlockSelectionType.cursor],
),
// local cursor
if (supportTypes.contains(BlockSelectionType.cursor)) if (supportTypes.contains(BlockSelectionType.cursor))
BlockSelectionArea( BlockSelectionArea(
node: node, node: node,

View file

@ -0,0 +1,210 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/base_component/selection/selection_area_painter.dart';
import 'package:appflowy_editor/src/render/selection/cursor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
final _deepEqual = const DeepCollectionEquality().equals;
class RemoteBlockSelectionsArea extends StatelessWidget {
const RemoteBlockSelectionsArea({
super.key,
required this.node,
required this.delegate,
required this.remoteSelections,
this.supportTypes = const [
BlockSelectionType.cursor,
BlockSelectionType.selection,
],
});
// get the cursor rect or selection rects from the delegate
final SelectableMixin delegate;
final ValueListenable<List<RemoteSelection>> remoteSelections;
// the node of the block
final Node node;
final List<BlockSelectionType> supportTypes;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: remoteSelections,
builder: (_, value, child) {
child ??= const SizedBox.shrink();
final selections = value
.where((e) => node.inSelection(e.selection.normalized))
.toList();
if (selections.isEmpty) {
return child;
}
return Positioned.fill(
child: Stack(
children: selections
.map(
(e) => RemoteBlockSelectionArea(
node: node,
delegate: delegate,
remoteSelection: e,
supportTypes: supportTypes,
),
)
.toList(),
),
);
},
child: const SizedBox.shrink(),
);
}
}
/// [RemoteBlockSelectionArea] is a widget that renders the selection area or the cursor of a block from remote.
class RemoteBlockSelectionArea extends StatefulWidget {
const RemoteBlockSelectionArea({
super.key,
required this.node,
required this.delegate,
required this.remoteSelection,
this.supportTypes = const [
BlockSelectionType.cursor,
BlockSelectionType.selection,
],
});
// get the cursor rect or selection rects from the delegate
final SelectableMixin delegate;
final RemoteSelection remoteSelection;
// the node of the block
final Node node;
final List<BlockSelectionType> supportTypes;
@override
State<RemoteBlockSelectionArea> createState() =>
_RemoteBlockSelectionAreaState();
}
class _RemoteBlockSelectionAreaState extends State<RemoteBlockSelectionArea> {
// keep the previous cursor rect to avoid unnecessary rebuild
Rect? prevCursorRect;
// keep the previous selection rects to avoid unnecessary rebuild
List<Rect>? prevSelectionRects;
// keep the block selection rect to avoid unnecessary rebuild
Rect? prevBlockRect;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateSelectionIfNeeded();
});
}
@override
Widget build(BuildContext context) {
const child = SizedBox.shrink();
final selection = widget.remoteSelection.selection;
if (selection.isCollapsed) {
// show the cursor when the selection is collapsed
if (!widget.supportTypes.contains(BlockSelectionType.cursor) ||
prevCursorRect == null) {
return child;
}
const shouldBlink = false;
final cursor = Stack(
clipBehavior: Clip.none,
children: [
Cursor(
rect: prevCursorRect!,
shouldBlink: shouldBlink,
cursorStyle: widget.delegate.cursorStyle,
color: widget.remoteSelection.cursorColor,
),
widget.remoteSelection.builder?.call(
context,
widget.remoteSelection,
prevCursorRect!,
) ??
child,
],
);
return cursor;
} else {
// show the selection area when the selection is not collapsed
if (!widget.supportTypes.contains(BlockSelectionType.selection) ||
prevSelectionRects == null ||
prevSelectionRects!.isEmpty ||
(prevSelectionRects!.length == 1 &&
prevSelectionRects!.first.width == 0)) {
return child;
}
return Stack(
clipBehavior: Clip.none,
children: [
SelectionAreaPaint(
rects: prevSelectionRects!,
selectionColor: widget.remoteSelection.selectionColor,
),
if (selection.start.path.equals(widget.node.path))
widget.remoteSelection.builder?.call(
context,
widget.remoteSelection,
prevSelectionRects!.first,
) ??
child,
],
);
}
}
void _updateSelectionIfNeeded() {
if (!mounted) {
return;
}
final selection = widget.remoteSelection.selection.normalized;
final path = widget.node.path;
// the current path is in the selection
if (path.inSelection(selection)) {
if (widget.supportTypes.contains(BlockSelectionType.cursor) &&
selection.isCollapsed) {
final rect = widget.delegate.getCursorRectInPosition(selection.start);
if (rect != prevCursorRect) {
setState(() {
prevCursorRect = rect;
prevBlockRect = null;
prevSelectionRects = null;
});
}
} else if (widget.supportTypes.contains(BlockSelectionType.selection)) {
final rects = widget.delegate.getRectsInSelection(selection);
if (!_deepEqual(rects, prevSelectionRects)) {
setState(() {
prevSelectionRects = rects;
prevCursorRect = null;
prevBlockRect = null;
});
}
}
} else if (prevBlockRect != null ||
prevSelectionRects != null ||
prevCursorRect != null) {
setState(() {
prevBlockRect = null;
prevSelectionRects = null;
prevCursorRect = null;
});
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_updateSelectionIfNeeded();
});
}
}

View file

@ -0,0 +1,22 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class RemoteSelection {
const RemoteSelection({
required this.id,
required this.selection,
required this.selectionColor,
required this.cursorColor,
this.builder,
});
final String id;
final Selection selection;
final Color selectionColor;
final Color cursorColor;
final Widget Function(
BuildContext context,
RemoteSelection selection,
Rect rect,
)? builder;
}

View file

@ -12,6 +12,8 @@ export 'base_component/outdent_command.dart';
// selection // selection
export 'base_component/selection/block_selection_area.dart'; export 'base_component/selection/block_selection_area.dart';
export 'base_component/selection/block_selection_container.dart'; export 'base_component/selection/block_selection_container.dart';
export 'base_component/selection/remote_block_selection_area.dart';
export 'base_component/selection/remote_selection.dart';
export 'base_component/text_direction_mixin.dart'; export 'base_component/text_direction_mixin.dart';
export 'base_component/text_style_configuration.dart'; export 'base_component/text_style_configuration.dart';
export 'base_component/widget/full_screen_overlay_entry.dart'; export 'base_component/widget/full_screen_overlay_entry.dart';

View file

@ -169,6 +169,7 @@ class _BulletedListBlockComponentWidgetState
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -111,6 +111,7 @@ class _DividerBlockComponentWidgetState
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
cursorColor: editorState.editorStyle.cursorColor, cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor, selectionColor: editorState.editorStyle.selectionColor,

View file

@ -184,6 +184,7 @@ class _HeadingBlockComponentWidgetState
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -162,6 +162,7 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,
@ -196,6 +197,7 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
cursorColor: editorState.editorStyle.cursorColor, cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor, selectionColor: editorState.editorStyle.selectionColor,
child: child!, child: child!,

View file

@ -175,6 +175,7 @@ class _NumberedListBlockComponentWidgetState
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -191,6 +191,7 @@ class _ParagraphBlockComponentWidgetState
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -163,6 +163,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -124,6 +124,7 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
return BlockSelectionContainer( return BlockSelectionContainer(
delegate: widget.delegate, delegate: widget.delegate,
listenable: widget.editorState.selectionNotifier, listenable: widget.editorState.selectionNotifier,
remoteSelection: widget.editorState.remoteSelections,
node: widget.node, node: widget.node,
cursorColor: widget.cursorColor, cursorColor: widget.cursorColor,
selectionColor: widget.selectionColor, selectionColor: widget.selectionColor,

View file

@ -165,6 +165,7 @@ class _TableBlockComponentWidgetState extends State<TableBlockComponentWidget>
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -196,6 +196,7 @@ class _TodoListBlockComponentWidgetState
node: node, node: node,
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [
BlockSelectionType.block, BlockSelectionType.block,

View file

@ -105,6 +105,10 @@ class EditorState {
/// The selection of the editor. /// The selection of the editor.
Selection? get selection => selectionNotifier.value; Selection? get selection => selectionNotifier.value;
/// Remote selection is the selection from other users.
final PropertyValueNotifier<List<RemoteSelection>> remoteSelections =
PropertyValueNotifier<List<RemoteSelection>>([]);
/// Sets the selection of the editor. /// Sets the selection of the editor.
set selection(Selection? value) { set selection(Selection? value) {
// clear the toggled style when the selection is changed. // clear the toggled style when the selection is changed.

View file

@ -1,103 +0,0 @@
import 'dart:async';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';
class CursorWidget extends StatefulWidget {
const CursorWidget({
super.key,
required this.layerLink,
required this.rect,
required this.color,
this.blinkingInterval = 0.5,
this.shouldBlink = true,
this.cursorStyle = CursorStyle.verticalLine,
});
final double blinkingInterval; // milliseconds
final bool shouldBlink;
final CursorStyle cursorStyle;
final Color color;
final Rect rect;
final LayerLink layerLink;
@override
State<CursorWidget> createState() => CursorWidgetState();
}
class CursorWidgetState extends State<CursorWidget> {
bool showCursor = true;
late Timer timer;
@override
void initState() {
super.initState();
timer = _initTimer();
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
Timer _initTimer() {
return Timer.periodic(
Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()),
(timer) => setState(() => showCursor = !showCursor),
);
}
/// force the cursor widget to show for a while
void show() {
setState(() {
showCursor = true;
});
timer.cancel();
timer = _initTimer();
}
@override
Widget build(BuildContext context) {
return Positioned.fromRect(
rect: widget.rect,
child: CompositedTransformFollower(
link: widget.layerLink,
offset: widget.rect.topCenter,
showWhenUnlinked: false,
// Ignore the gestures in cursor
// to solve the problem that cursor area cannot be selected.
child: IgnorePointer(
child: _buildCursor(context),
),
),
);
}
Widget _buildCursor(BuildContext context) {
var color = widget.color;
if (widget.shouldBlink && !showCursor) {
color = Colors.transparent;
}
switch (widget.cursorStyle) {
case CursorStyle.verticalLine:
return Container(
color: color,
);
case CursorStyle.borderLine:
return Container(
decoration: BoxDecoration(
border: Border.all(color: color, width: 2),
),
);
case CursorStyle.cover:
final size = widget.rect.size;
return Container(
width: size.width,
height: size.height,
color: color.withOpacity(0.2),
);
}
}
}

View file

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
class SelectionWidget extends StatefulWidget {
const SelectionWidget({
super.key,
required this.layerLink,
required this.rect,
required this.color,
this.decoration,
});
final Color color;
final Rect rect;
final LayerLink layerLink;
final BoxDecoration? decoration;
@override
State<SelectionWidget> createState() => _SelectionWidgetState();
}
class _SelectionWidgetState extends State<SelectionWidget> {
@override
Widget build(BuildContext context) {
return Positioned.fromRect(
rect: widget.rect,
child: CompositedTransformFollower(
link: widget.layerLink,
offset: widget.rect.topLeft,
showWhenUnlinked: false,
// Ignore the gestures in selection overlays
// to solve the problem that selection areas cannot overlap.
child: IgnorePointer(
child: Container(
color: widget.decoration == null ? widget.color : null,
decoration: widget.decoration,
),
),
),
);
}
}

View file

@ -1,66 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/selection/cursor_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test_helper.dart';
void main() {
group('CursorWidget', () {
testWidgets('can render verticalLine', (tester) async {
final node = Node(type: 'paragraph');
await tester.buildAndPump(
Stack(
children: [
CursorWidget(
layerLink: node.layerLink,
rect: const Rect.fromLTWH(0, 0, 50, 50),
color: Colors.blue,
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(CursorWidget), findsOneWidget);
});
testWidgets('can render borderLine', (tester) async {
final node = Node(type: 'paragraph');
await tester.buildAndPump(
Stack(
children: [
CursorWidget(
cursorStyle: CursorStyle.borderLine,
layerLink: node.layerLink,
rect: const Rect.fromLTWH(0, 0, 50, 50),
color: Colors.blue,
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(CursorWidget), findsOneWidget);
});
testWidgets('can render cover', (tester) async {
final node = Node(type: 'paragraph');
await tester.buildAndPump(
Stack(
children: [
CursorWidget(
cursorStyle: CursorStyle.cover,
layerLink: node.layerLink,
rect: const Rect.fromLTWH(0, 0, 50, 50),
color: Colors.blue,
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(CursorWidget), findsOneWidget);
});
});
}