feat: optimize the performance (#442)

This commit is contained in:
Lucas.Xu 2023-09-11 15:03:14 +08:00 • committed by GitHub
commit 19f999f422
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 4639 additions and 828 deletions

2
codecov.yml Normal file
View file

@ -0,0 +1,2 @@
ignore:
- "lib/src/flutter/"

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/customize_theme_for_editor.dart';
@ -51,6 +52,7 @@ class _HomePageState extends State<HomePage> {
super.initState();
_jsonString = rootBundle.loadString('assets/example.json');
_widgetBuilder = (context) => Editor(
jsonString: _jsonString,
onEditorStateChange: (editorState) {
@ -109,6 +111,20 @@ class _HomePageState extends State<HomePage> {
final jsonString = rootBundle.loadString('assets/example.json');
_loadEditor(context, jsonString);
}),
_buildListTile(context, 'With Large Document (10000+ lines)', () {
final nodes = List.generate(
10000,
(index) =>
paragraphNode(text: '$index ${generateRandomString(50)}'),
);
final editorState = EditorState(
document: Document(root: pageNode(children: nodes)),
);
final jsonString = Future.value(
jsonEncode(editorState.document.toJson()),
);
_loadEditor(context, jsonString);
}),
_buildListTile(context, 'With Example.html', () async {
final htmlString =
await rootBundle.loadString('assets/example.html');
@ -315,3 +331,10 @@ class _HomePageState extends State<HomePage> {
}
}
}
String generateRandomString(int len) {
var r = Random();
return String.fromCharCodes(
List.generate(len, (index) => r.nextInt(33) + 89),
);
}

View file

@ -1,10 +1,8 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/home_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
void main() {
runApp(const MyApp());
}

View file

@ -37,7 +37,10 @@ class Editor extends StatelessWidget {
onEditorStateChange(editorState);
}
});
final scrollController = ScrollController();
final editorScrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: false,
);
if (PlatformExtension.isDesktopOrWeb) {
return FloatingToolbar(
items: [
@ -54,11 +57,11 @@ class Editor extends StatelessWidget {
...alignmentItems,
],
editorState: editorState,
scrollController: scrollController,
editorScrollController: editorScrollController,
child: _buildDesktopEditor(
context,
editorState,
scrollController,
editorScrollController,
),
);
} else if (PlatformExtension.isMobile) {
@ -68,7 +71,7 @@ class Editor extends StatelessWidget {
child: _buildMobileEditor(
context,
editorState,
scrollController,
editorScrollController,
),
),
MobileToolbar(
@ -99,19 +102,19 @@ class Editor extends StatelessWidget {
Widget _buildMobileEditor(
BuildContext context,
EditorState editorState,
ScrollController? scrollController,
EditorScrollController? editorScrollController,
) {
return AppFlowyEditor(
editorStyle: const EditorStyle.mobile(),
editorState: editorState,
scrollController: scrollController,
editorScrollController: editorScrollController,
);
}
Widget _buildDesktopEditor(
BuildContext context,
EditorState editorState,
ScrollController? scrollController,
EditorScrollController? editorScrollController,
) {
final customBlockComponentBuilders = {
...standardBlockComponentBuilderMap,
@ -127,7 +130,8 @@ class Editor extends StatelessWidget {
};
return AppFlowyEditor(
editorState: editorState,
scrollController: scrollController,
shrinkWrap: true,
editorScrollController: editorScrollController,
blockComponentBuilders: customBlockComponentBuilders,
commandShortcutEvents: [
customToggleHighlightCommand(

View file

@ -42,6 +42,7 @@ dependencies:
universal_html: ^2.0.8
highlight: ^0.7.0
http: ^0.13.5
show_fps: ^1.0.6
dev_dependencies:
flutter_test:

View file

@ -2,7 +2,7 @@ import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:nanoid/nanoid.dart';
import 'package:nanoid/non_secure.dart';
abstract class NodeExternalValues {
const NodeExternalValues();
@ -39,8 +39,8 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
),
), // unlink the given children to avoid the error of "node has already a parent"
_attributes = attributes,
id = id ?? nanoid(10) {
for (final child in this.children) {
id = id ?? nanoid(6) {
for (final child in children) {
child.parent = this;
}
}
@ -80,7 +80,12 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
/// The children of the node.
final LinkedList<Node> _children;
Iterable<Node> get children => _children.toList(growable: false);
List<Node> get children {
_cacheChildren ??= _children.toList(growable: false);
return _cacheChildren!;
}
List<Node>? _cacheChildren;
/// The attributes of the node.
Attributes _attributes;
@ -141,7 +146,9 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
entry.parent = this;
if (children.isEmpty) {
_cacheChildren = null;
if (_children.isEmpty) {
_children.add(entry);
notifyListeners();
return;
@ -164,6 +171,8 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
entry.parent = parent;
super.insertAfter(entry);
parent?._cacheChildren = null;
// Notifies the new node.
parent?.notifyListeners();
}
@ -173,6 +182,8 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
entry.parent = parent;
super.insertBefore(entry);
parent?._cacheChildren = null;
// Notifies the new node.
parent?.notifyListeners();
}
@ -185,6 +196,8 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
Log.editor.debug('delete Node $this from path $path');
super.unlink();
parent?._cacheChildren = null;
parent?.notifyListeners();
parent = null;
}
@ -230,12 +243,12 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
}) {
final node = Node(
type: type ?? this.type,
id: nanoid(10),
id: nanoid(6),
attributes: attributes ?? {...this.attributes},
children: children ?? [],
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
if (children == null && _children.isNotEmpty) {
for (final child in _children) {
node._children.add(
child.copyWith()..parent = node,
);
@ -246,11 +259,12 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
}
Path _computePath([Path previous = const []]) {
final parent = this.parent;
if (parent == null) {
return previous;
}
final index = parent!.children.toList().indexOf(this);
return parent!._computePath([index, ...previous]);
final index = parent.children.indexOf(this);
return parent._computePath([index, ...previous]);
}
}

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
typedef Path = List<int>;
@ -105,4 +106,10 @@ extension PathExtensions on Path {
}
return true;
}
bool inSelection(Selection? selection) {
return selection != null &&
selection.start.path <= this &&
this <= selection.end.path;
}
}

View file

@ -0,0 +1,193 @@
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';
import 'package:provider/provider.dart';
final _deepEqual = const DeepCollectionEquality().equals;
enum BlockSelectionType {
cursor,
selection,
block,
}
/// [BlockSelectionArea] is a widget that renders the selection area or the cursor of a block.
class BlockSelectionArea extends StatefulWidget {
const BlockSelectionArea({
super.key,
required this.node,
required this.delegate,
required this.listenable,
required this.cursorColor,
required this.selectionColor,
required this.blockColor,
this.supportTypes = const [
BlockSelectionType.cursor,
BlockSelectionType.selection
],
});
// get the cursor rect or selection rects from the delegate
final SelectableMixin delegate;
// get the selection from the listenable
final ValueListenable<Selection?> listenable;
// the color of the cursor
final Color cursorColor;
// the color of the selection
final Color selectionColor;
final Color blockColor;
// the node of the block
final Node node;
final List<BlockSelectionType> supportTypes;
@override
State<BlockSelectionArea> createState() => _BlockSelectionAreaState();
}
class _BlockSelectionAreaState extends State<BlockSelectionArea> {
// We need to keep the key to refresh the cursor status when typing continuously.
late GlobalKey cursorKey = GlobalKey(
debugLabel: 'cursor_${widget.node.path}',
);
// 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) {
return ValueListenableBuilder(
key: ValueKey(widget.node.id + widget.supportTypes.toString()),
valueListenable: widget.listenable,
builder: ((context, value, child) {
final sizedBox = child ?? const SizedBox.shrink();
final selection = value?.normalized;
if (selection == null) {
return sizedBox;
}
final path = widget.node.path;
if (!path.inSelection(selection)) {
return sizedBox;
}
if (context.read<EditorState>().selectionType == SelectionType.block) {
if (!widget.supportTypes.contains(BlockSelectionType.block) ||
!path.equals(selection.start.path) ||
prevBlockRect == null) {
return sizedBox;
}
return Positioned.fromRect(
rect: prevBlockRect!,
child: Container(
decoration: BoxDecoration(
color: widget.blockColor,
borderRadius: BorderRadius.circular(0.0),
),
),
);
}
// show the cursor when the selection is collapsed
else if (selection.isCollapsed) {
if (!widget.supportTypes.contains(BlockSelectionType.cursor) ||
prevCursorRect == null) {
return sizedBox;
}
final cursor = Cursor(
key: cursorKey,
rect: prevCursorRect!,
shouldBlink: widget.delegate.shouldCursorBlink,
cursorStyle: widget.delegate.cursorStyle,
color: widget.cursorColor,
);
// force to show the cursor
cursorKey.currentState?.unwrapOrNull<CursorState>()?.show();
return cursor;
} else {
// show the selection area when the selection is not collapsed
if (!widget.supportTypes.contains(BlockSelectionType.selection) ||
prevSelectionRects == null ||
prevSelectionRects!.isEmpty) {
return sizedBox;
}
return SelectionAreaPaint(
rects: prevSelectionRects!,
selectionColor: widget.selectionColor,
);
}
}),
child: const SizedBox.shrink(),
);
}
void _updateSelectionIfNeeded() {
if (!mounted) {
return;
}
final selection = widget.listenable.value?.normalized;
final path = widget.node.path;
// the current path is in the selection
if (selection != null && path.inSelection(selection)) {
if (widget.supportTypes.contains(BlockSelectionType.block)) {
if (context.read<EditorState>().selectionType != SelectionType.block ||
!path.equals(selection.start.path)) {
if (prevBlockRect != null) {
setState(() {
prevBlockRect = null;
});
}
} else {
final rect = widget.delegate.getBlockRect();
if (prevBlockRect != rect) {
setState(() {
prevBlockRect = rect;
});
}
}
} else if (widget.supportTypes.contains(BlockSelectionType.cursor) &&
selection.isCollapsed) {
final rect = widget.delegate.getCursorRectInPosition(selection.start);
if (rect != prevCursorRect) {
setState(() {
prevCursorRect = rect;
});
}
} else if (widget.supportTypes.contains(BlockSelectionType.selection)) {
final rects = widget.delegate.getRectsInSelection(selection);
if (!_deepEqual(rects, prevSelectionRects)) {
setState(() {
prevSelectionRects = rects;
});
}
}
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_updateSelectionIfNeeded();
});
}
}

View file

@ -0,0 +1,61 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class BlockSelectionContainer extends StatelessWidget {
const BlockSelectionContainer({
super.key,
required this.node,
required this.delegate,
required this.listenable,
this.cursorColor = Colors.black,
this.selectionColor = Colors.blue,
this.blockColor = Colors.blue,
this.supportTypes = const [
BlockSelectionType.cursor,
BlockSelectionType.selection
],
required this.child,
});
// get the cursor rect, selection rects or block rect from the delegate
final SelectableMixin delegate;
// get the selection from the listenable
final ValueListenable<Selection?> listenable;
// the color of the cursor
final Color cursorColor;
// the color of the selection
final Color selectionColor;
// the color of the background of the block
final Color blockColor;
// the node of the block
final Node node;
final List<BlockSelectionType> supportTypes;
final Widget child;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
BlockSelectionArea(
node: node,
delegate: delegate,
listenable: listenable,
cursorColor: cursorColor,
selectionColor: selectionColor,
blockColor: blockColor,
supportTypes: supportTypes,
),
child,
],
);
}
}

View file

@ -0,0 +1,163 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
class AnimatedSelectionAreaPaint extends StatefulWidget {
const AnimatedSelectionAreaPaint({
super.key,
required this.rects,
this.withAnimation = false,
s,
});
final List<Rect> rects;
final bool withAnimation;
@override
State<AnimatedSelectionAreaPaint> createState() =>
_AnimatedSelectionAreaPaintState();
}
class _AnimatedSelectionAreaPaintState extends State<AnimatedSelectionAreaPaint>
with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
if (widget.withAnimation) {
controller = AnimationController(
duration: const Duration(seconds: 4),
vsync: this,
);
animation = Tween<double>(begin: 0, end: 1.0).animate(
CurvedAnimation(
parent: controller,
curve: Curves.bounceInOut,
),
);
controller.repeat();
}
}
@override
Widget build(BuildContext context) {
Widget builder(double value) {
return CustomPaint(
painter: AnimatedSelectionAreaPainter(
colors: const [
Color.fromARGB(255, 65, 88, 208),
Color.fromARGB(255, 200, 80, 192),
Color.fromARGB(255, 255, 204, 112),
],
animation: value,
rects: widget.rects,
),
);
}
if (!widget.withAnimation) {
return builder(1.0);
}
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, _) {
return builder(animation.value);
},
);
}
@override
void dispose() {
if (widget.withAnimation) {
controller.dispose();
}
super.dispose();
}
}
class SelectionAreaPaint extends StatelessWidget {
const SelectionAreaPaint({
super.key,
required this.rects,
required this.selectionColor,
});
final List<Rect> rects;
final Color selectionColor;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: SelectionAreaPainter(
rects: rects,
selectionColor: selectionColor,
),
);
}
}
class SelectionAreaPainter extends CustomPainter {
SelectionAreaPainter({
required this.rects,
required this.selectionColor,
});
final List<Rect> rects;
final Color selectionColor;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = selectionColor
..style = PaintingStyle.fill;
for (final rect in rects) {
canvas.drawRect(rect, paint);
}
}
@override
bool shouldRepaint(SelectionAreaPainter oldDelegate) {
return selectionColor != oldDelegate.selectionColor ||
!const DeepCollectionEquality().equals(rects, oldDelegate.rects);
}
}
class AnimatedSelectionAreaPainter extends CustomPainter {
const AnimatedSelectionAreaPainter({
required this.rects,
required this.colors,
required this.animation,
});
final List<Rect> rects;
final List<Color> colors;
final double animation;
@override
void paint(Canvas canvas, Size size) {
for (final rect in rects) {
final paint = Paint()
..shader = LinearGradient(
colors: colors,
transform: GradientRotation(animation * 2 * pi),
).createShader(rect)
..style = PaintingStyle.fill;
canvas.drawRect(rect, paint);
}
}
@override
bool shouldRepaint(covariant AnimatedSelectionAreaPainter oldDelegate) {
return animation != oldDelegate.animation ||
!const DeepCollectionEquality().equals(colors, oldDelegate.colors) ||
!const DeepCollectionEquality().equals(rects, oldDelegate.rects);
}
}

View file

@ -9,6 +9,9 @@ export 'base_component/indent_command.dart';
export 'base_component/insert_newline_in_type_command.dart';
export 'base_component/markdown_format_helper.dart';
export 'base_component/outdent_command.dart';
// selection
export 'base_component/selection/block_selection_area.dart';
export 'base_component/selection/block_selection_container.dart';
export 'base_component/text_direction_mixin.dart';
export 'base_component/text_style_configuration.dart';
export 'base_component/widget/full_screen_overlay_entry.dart';

View file

@ -134,6 +134,7 @@ class _BulletedListBlockComponentWidgetState
Flexible(
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
textAlign: alignment?.toTextAlign,
@ -146,6 +147,8 @@ class _BulletedListBlockComponentWidgetState
placeholderTextStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
),
],
@ -158,6 +161,17 @@ class _BulletedListBlockComponentWidgetState
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,

View file

@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DividerBlockKeys {
const DividerBlockKeys._();
@ -77,7 +78,7 @@ class _DividerBlockComponentWidgetState
Node get node => widget.node;
final dividerKey = GlobalKey();
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
@override
Widget build(BuildContext context) {
@ -96,6 +97,19 @@ class _DividerBlockComponentWidgetState
child: child,
);
final editorState = context.read<EditorState>();
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
@ -104,7 +118,15 @@ class _DividerBlockComponentWidgetState
);
}
return child;
final selectionNotifier = editorState.selectionNotifier;
return BlockSelectionContainer(
node: node,
delegate: this,
listenable: selectionNotifier,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
child: child,
);
}
@override
@ -128,22 +150,38 @@ class _DividerBlockComponentWidgetState
}
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return null;
}
return getRectsInSelection(
Selection.collapsed(position),
shiftWithBaseOffset: shiftWithBaseOffset,
).firstOrNull;
}
@override
List<Rect> getRectsInSelection(Selection selection) {
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return [];
}
final parentBox = context.findRenderObject();
final dividerBox = dividerKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && dividerBox is RenderBox) {
return [
dividerBox.localToGlobal(Offset.zero, ancestor: parentBox) &
(shiftWithBaseOffset
? dividerBox.localToGlobal(Offset.zero, ancestor: parentBox)
: Offset.zero) &
dividerBox.size
];
}
return [Offset.zero & _renderBox.size];
return [Offset.zero & _renderBox!.size];
}
@override
@ -154,7 +192,11 @@ class _DividerBlockComponentWidgetState
);
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) =>
_renderBox!.localToGlobal(offset);
@override
TextDirection textDirection() {

View file

@ -140,6 +140,7 @@ class _HeadingBlockComponentWidgetState
Flexible(
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
textAlign: alignment?.toTextAlign,
@ -158,6 +159,8 @@ class _HeadingBlockComponentWidgetState
defaultTextStyle(level),
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
),
],
@ -170,6 +173,17 @@ class _HeadingBlockComponentWidgetState
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,

View file

@ -121,7 +121,7 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
Node get node => widget.node;
final imageKey = GlobalKey();
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
late final editorState = Provider.of<EditorState>(context, listen: false);
@ -163,6 +163,17 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
@ -186,7 +197,14 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
builder: (context, value, child) {
return Stack(
children: [
child!,
BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
child: child!,
),
if (value) widget.menuBuilder!(widget.node, this),
],
);
@ -220,13 +238,25 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
}
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return null;
}
final size = _renderBox!.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return [];
}
final parentBox = context.findRenderObject();
final dividerBox = imageKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && dividerBox is RenderBox) {
@ -235,7 +265,7 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
dividerBox.size
];
}
return [Offset.zero & _renderBox.size];
return [Offset.zero & _renderBox!.size];
}
@override
@ -246,7 +276,11 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
);
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) =>
_renderBox!.localToGlobal(offset);
}
extension AlignmentExtension on Alignment {

View file

@ -140,6 +140,7 @@ class _NumberedListBlockComponentWidgetState
Flexible(
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
textAlign: alignment?.toTextAlign,
@ -152,6 +153,8 @@ class _NumberedListBlockComponentWidgetState
placeholderTextStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
),
],
@ -164,6 +167,17 @@ class _NumberedListBlockComponentWidgetState
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,

View file

@ -131,6 +131,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
Flexible(
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
textAlign: alignment?.toTextAlign,
@ -143,6 +144,8 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
placeholderTextStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
),
],
@ -156,6 +159,17 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,

View file

@ -1,3 +1,4 @@
import 'dart:math';
import 'dart:ui';
import 'package:appflowy_editor/src/core/document/attributes.dart';
@ -6,6 +7,7 @@ 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/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor/block_component/base_component/selection/block_selection_container.dart';
import 'package:appflowy_editor/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart';
import 'package:appflowy_editor/src/editor/util/color_util.dart';
import 'package:appflowy_editor/src/editor_state.dart';
@ -28,7 +30,7 @@ class AppFlowyRichText extends StatefulWidget {
const AppFlowyRichText({
super.key,
this.cursorHeight,
this.cursorWidth = 1.5,
this.cursorWidth = 2.0,
this.lineHeight,
this.textSpanDecorator,
this.placeholderText = ' ',
@ -36,6 +38,9 @@ class AppFlowyRichText extends StatefulWidget {
this.textDirection = TextDirection.ltr,
this.textSpanDecoratorForCustomAttributes,
this.textAlign,
this.cursorColor = const Color.fromARGB(255, 0, 0, 0),
this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
required this.delegate,
required this.node,
required this.editorState,
});
@ -68,6 +73,9 @@ class AppFlowyRichText extends StatefulWidget {
final TextAlign? textAlign;
// get the cursor rect, selection rects or block rect from the delegate
final SelectableMixin delegate;
/// customize the text span for custom attributes
///
/// You can use this to customize the text span for custom attributes
@ -75,6 +83,9 @@ class AppFlowyRichText extends StatefulWidget {
final TextSpanDecoratorForAttribute? textSpanDecoratorForCustomAttributes;
final TextDirection textDirection;
final Color cursorColor;
final Color selectionColor;
@override
State<AppFlowyRichText> createState() => _AppFlowyRichTextState();
}
@ -84,8 +95,8 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
final textKey = GlobalKey();
final placeholderTextKey = GlobalKey();
RenderParagraph get _renderParagraph =>
textKey.currentContext?.findRenderObject() as RenderParagraph;
RenderParagraph? get _renderParagraph =>
textKey.currentContext?.findRenderObject() as RenderParagraph?;
RenderParagraph? get _placeholderRenderParagraph =>
placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph?;
@ -96,7 +107,7 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
@override
Widget build(BuildContext context) {
return MouseRegion(
final child = MouseRegion(
cursor: SystemMouseCursors.text,
child: widget.node.delta?.toPlainText().isEmpty ?? true
? Stack(
@ -107,6 +118,15 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
)
: _buildRichText(context),
);
return BlockSelectionContainer(
delegate: widget.delegate,
listenable: widget.editorState.selectionNotifier,
node: widget.node,
cursorColor: widget.cursorColor,
selectionColor: widget.selectionColor,
child: child,
);
}
@override
@ -124,12 +144,15 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
}
@override
Rect? getCursorRectInPosition(Position position) {
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final textPosition = TextPosition(offset: position.offset);
var cursorHeight = _renderParagraph.getFullHeightForCaret(textPosition);
var cursorHeight = _renderParagraph?.getFullHeightForCaret(textPosition);
var cursorOffset =
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
_renderParagraph?.getOffsetForCaret(textPosition, Rect.zero) ??
Offset.zero;
if (cursorHeight == null) {
cursorHeight =
_placeholderRenderParagraph?.getFullHeightForCaret(textPosition);
@ -155,7 +178,7 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
cursorHeight = widget.cursorHeight;
}
final rect = Rect.fromLTWH(
cursorOffset.dx - (widget.cursorWidth / 2.0),
max(0, cursorOffset.dx - (widget.cursorWidth / 2.0)),
cursorOffset.dy,
widget.cursorWidth,
cursorHeight ?? 16.0,
@ -165,16 +188,19 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
@override
Position getPositionInOffset(Offset start) {
final offset = _renderParagraph.globalToLocal(start);
final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
final offset = _renderParagraph?.globalToLocal(start) ?? Offset.zero;
final baseOffset =
_renderParagraph?.getPositionForOffset(offset).offset ?? -1;
return Position(path: widget.node.path, offset: baseOffset);
}
@override
Selection? getWordBoundaryInOffset(Offset offset) {
final localOffset = _renderParagraph.globalToLocal(offset);
final textPosition = _renderParagraph.getPositionForOffset(localOffset);
final textRange = _renderParagraph.getWordBoundary(textPosition);
final localOffset = _renderParagraph?.globalToLocal(offset) ?? Offset.zero;
final textPosition = _renderParagraph?.getPositionForOffset(localOffset) ??
const TextPosition(offset: 0);
final textRange =
_renderParagraph?.getWordBoundary(textPosition) ?? TextRange.empty;
final start = Position(path: widget.node.path, offset: textRange.start);
final end = Position(path: widget.node.path, offset: textRange.end);
return Selection(start: start, end: end);
@ -183,37 +209,47 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
@override
Selection? getWordBoundaryInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset);
final textRange = _renderParagraph.getWordBoundary(textPosition);
final textRange =
_renderParagraph?.getWordBoundary(textPosition) ?? TextRange.empty;
final start = Position(path: widget.node.path, offset: textRange.start);
final end = Position(path: widget.node.path, offset: textRange.end);
return Selection(start: start, end: end);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final textSelection = textSelectionFromEditorSelection(selection);
if (textSelection == null) {
return [];
}
final rects = _renderParagraph
.getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max)
?.getBoxesForSelection(
textSelection,
boxHeightStyle: BoxHeightStyle.max,
)
.map((box) => box.toRect())
.toList(growable: false);
if (rects.isEmpty) {
if (rects == null || rects.isEmpty) {
// If the rich text widget does not contain any text,
// there will be no selection boxes,
// so we need to return to the default selection.
return [Rect.fromLTWH(0, 0, 0, _renderParagraph.size.height)];
return [Rect.fromLTWH(0, 0, 0, _renderParagraph?.size.height ?? 0)];
}
return rects;
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
final localStart = _renderParagraph.globalToLocal(start);
final localEnd = _renderParagraph.globalToLocal(end);
final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
final localStart = _renderParagraph?.globalToLocal(start) ?? Offset.zero;
final localEnd = _renderParagraph?.globalToLocal(end) ?? Offset.zero;
final baseOffset =
_renderParagraph?.getPositionForOffset(localStart).offset ?? -1;
final extentOffset =
_renderParagraph?.getPositionForOffset(localEnd).offset ?? -1;
return Selection.single(
path: widget.node.path,
startOffset: baseOffset,
@ -222,8 +258,11 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
}
@override
Offset localToGlobal(Offset offset) {
return _renderParagraph.localToGlobal(offset);
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) {
return _renderParagraph?.localToGlobal(offset) ?? Offset.zero;
}
@override

View file

@ -9,11 +9,15 @@ mixin DefaultSelectableMixin {
SelectableMixin<StatefulWidget> get forward =>
forwardKey.currentState as SelectableMixin;
Offset get baseOffset {
final parentBox = containerKey.currentContext?.findRenderObject();
final childBox = forwardKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && childBox is RenderBox) {
return childBox.localToGlobal(Offset.zero, ancestor: parentBox);
Offset baseOffset({
bool shiftWithBaseOffset = false,
}) {
if (shiftWithBaseOffset) {
final parentBox = containerKey.currentContext?.findRenderObject();
final childBox = forwardKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && childBox is RenderBox) {
return childBox.localToGlobal(Offset.zero, ancestor: parentBox);
}
}
return Offset.zero;
}
@ -24,7 +28,7 @@ mixin DefaultSelectableMixin {
if (parentBox is RenderBox && childBox is RenderBox) {
final offset = childBox.localToGlobal(Offset.zero, ancestor: parentBox);
final size = parentBox.size;
return offset & (size - offset as Size);
return Offset.zero & (size - offset as Size);
}
return Rect.zero;
}
@ -32,19 +36,42 @@ mixin DefaultSelectableMixin {
Position getPositionInOffset(Offset start) =>
forward.getPositionInOffset(start);
Rect? getCursorRectInPosition(Position position) =>
forward.getCursorRectInPosition(position)?.shift(baseOffset);
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) =>
forward.getCursorRectInPosition(position)?.shift(
baseOffset(
shiftWithBaseOffset: shiftWithBaseOffset,
),
);
List<Rect> getRectsInSelection(Selection selection) => forward
.getRectsInSelection(selection)
.map((rect) => rect.shift(baseOffset))
.toList(growable: false);
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) =>
forward
.getRectsInSelection(selection)
.map(
(rect) => rect.shift(
baseOffset(
shiftWithBaseOffset: shiftWithBaseOffset,
),
),
)
.toList(growable: false);
Selection getSelectionInRange(Offset start, Offset end) =>
forward.getSelectionInRange(start, end);
Offset localToGlobal(Offset offset) =>
forward.localToGlobal(offset) - baseOffset;
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) =>
forward.localToGlobal(offset) -
baseOffset(
shiftWithBaseOffset: shiftWithBaseOffset,
);
Selection? getWordBoundaryInOffset(Offset offset) =>
forward.getWordBoundaryInOffset(offset);

View file

@ -164,6 +164,17 @@ class _TableBlockComponentWidgetState extends State<TableBlockComponentWidget>
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
@ -188,12 +199,18 @@ class _TableBlockComponentWidgetState extends State<TableBlockComponentWidget>
Position getPositionInOffset(Offset start) => end();
@override
List<Rect> getRectsInSelection(Selection selection) {
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final parentBox = context.findRenderObject();
final tableBox = tableKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && tableBox is RenderBox) {
return [
tableBox.localToGlobal(Offset.zero, ancestor: parentBox) & tableBox.size
(shiftWithBaseOffset
? tableBox.localToGlobal(Offset.zero, ancestor: parentBox)
: Offset.zero) &
tableBox.size
];
}
return [Offset.zero & _renderBox.size];
@ -213,7 +230,11 @@ class _TableBlockComponentWidgetState extends State<TableBlockComponentWidget>
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) =>
_renderBox.localToGlobal(offset);
@override
Rect getBlockRect() {
@ -221,7 +242,10 @@ class _TableBlockComponentWidgetState extends State<TableBlockComponentWidget>
}
@override
Rect? getCursorRectInPosition(Position position) {
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}

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