fix: enable dismiss floating toolbar from outside (#1073)
* chore: update comment of the editable field in editor * fix: assertion in editor list example * feat: add text span overlay * feat: add textSpanOverlayBuilder in edtor style * fix: enable dismiss floating toolbar from outside * chore: make sure context of global key in rich text is available for overlay --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
parent
f2685cb900
commit
5bcc4e8fd9
6 changed files with 185 additions and 3 deletions
|
|
@ -41,7 +41,7 @@
|
||||||
"delta": [
|
"delta": [
|
||||||
{
|
{
|
||||||
"insert": "Customizable",
|
"insert": "Customizable",
|
||||||
"attributes": { "bg_color": "0x4d00BCF0" }
|
"attributes": { "href": "https://appflowy.io" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,9 +129,63 @@ class _DesktopEditorState extends State<DesktopEditor> {
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||||
maxWidth: 640,
|
maxWidth: 640,
|
||||||
|
textSpanOverlayBuilder: _buildTextSpanOverlay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildTextSpanOverlay(
|
||||||
|
BuildContext context,
|
||||||
|
Node node,
|
||||||
|
SelectableMixin delegate,
|
||||||
|
) {
|
||||||
|
final delta = node.delta;
|
||||||
|
if (delta == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
final textInserts = delta.whereType<TextInsert>();
|
||||||
|
int index = 0;
|
||||||
|
for (final textInsert in textInserts) {
|
||||||
|
final rects = delegate.getRectsInSelection(
|
||||||
|
Selection(
|
||||||
|
start: Position(path: node.path, offset: index),
|
||||||
|
end: Position(
|
||||||
|
path: node.path,
|
||||||
|
offset: index + textInsert.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Add a hover menu to the linked text.
|
||||||
|
if (rects.isNotEmpty && textInsert.attributes?.href != null) {
|
||||||
|
widgets.add(
|
||||||
|
Positioned(
|
||||||
|
left: rects.first.left,
|
||||||
|
top: rects.first.top,
|
||||||
|
child: HoverMenu(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.red.withValues(alpha: 0.5),
|
||||||
|
width: rects.first.width,
|
||||||
|
height: rects.first.height,
|
||||||
|
),
|
||||||
|
itemBuilder: (context) => Material(
|
||||||
|
color: Colors.blue,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 200,
|
||||||
|
height: 48,
|
||||||
|
child: Text(
|
||||||
|
'This is a hover menu:\n${textInsert.attributes?.href}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
index += textInsert.length;
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
// showcase 2: customize the block style
|
// showcase 2: customize the block style
|
||||||
Map<String, BlockComponentBuilder> _buildBlockComponentBuilders() {
|
Map<String, BlockComponentBuilder> _buildBlockComponentBuilders() {
|
||||||
final map = {
|
final map = {
|
||||||
|
|
@ -273,3 +327,69 @@ class _DesktopEditorState extends State<DesktopEditor> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HoverMenu extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final WidgetBuilder itemBuilder;
|
||||||
|
|
||||||
|
const HoverMenu({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.itemBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
HoverMenuState createState() => HoverMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class HoverMenuState extends State<HoverMenu> {
|
||||||
|
OverlayEntry? overlayEntry;
|
||||||
|
|
||||||
|
bool canCancelHover = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.text,
|
||||||
|
hitTestBehavior: HitTestBehavior.opaque,
|
||||||
|
onEnter: (details) {
|
||||||
|
overlayEntry = _createOverlayEntry();
|
||||||
|
Overlay.of(context).insert(overlayEntry!);
|
||||||
|
},
|
||||||
|
onExit: (details) {
|
||||||
|
// delay the removal of the overlay entry to avoid flickering.
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
if (canCancelHover) {
|
||||||
|
overlayEntry?.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayEntry _createOverlayEntry() {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox;
|
||||||
|
final offset = renderBox.localToGlobal(Offset.zero);
|
||||||
|
final size = renderBox.size;
|
||||||
|
|
||||||
|
return OverlayEntry(
|
||||||
|
maintainState: true,
|
||||||
|
builder: (context) => Positioned(
|
||||||
|
left: offset.dx,
|
||||||
|
top: offset.dy + size.height,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.text,
|
||||||
|
hitTestBehavior: HitTestBehavior.opaque,
|
||||||
|
onEnter: (details) {
|
||||||
|
canCancelHover = false;
|
||||||
|
},
|
||||||
|
onExit: (details) {
|
||||||
|
canCancelHover = true;
|
||||||
|
},
|
||||||
|
child: widget.itemBuilder(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ typedef AppFlowyAutoCompleteTextProvider = String? Function(
|
||||||
TextSpan? textSpan,
|
TextSpan? textSpan,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
typedef AppFlowyTextSpanOverlayBuilder = List<Widget> Function(
|
||||||
|
BuildContext context,
|
||||||
|
Node node,
|
||||||
|
SelectableMixin delegate,
|
||||||
|
);
|
||||||
|
|
||||||
class AppFlowyRichText extends StatefulWidget {
|
class AppFlowyRichText extends StatefulWidget {
|
||||||
const AppFlowyRichText({
|
const AppFlowyRichText({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -33,6 +39,7 @@ class AppFlowyRichText extends StatefulWidget {
|
||||||
this.placeholderTextSpanDecorator,
|
this.placeholderTextSpanDecorator,
|
||||||
this.textDirection = TextDirection.ltr,
|
this.textDirection = TextDirection.ltr,
|
||||||
this.textSpanDecoratorForCustomAttributes,
|
this.textSpanDecoratorForCustomAttributes,
|
||||||
|
this.textSpanOverlayBuilder,
|
||||||
this.textAlign,
|
this.textAlign,
|
||||||
this.cursorColor = const Color.fromARGB(255, 0, 0, 0),
|
this.cursorColor = const Color.fromARGB(255, 0, 0, 0),
|
||||||
this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
|
this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
|
||||||
|
|
@ -81,6 +88,12 @@ class AppFlowyRichText extends StatefulWidget {
|
||||||
/// You can use this to customize the text span for custom attributes
|
/// You can use this to customize the text span for custom attributes
|
||||||
/// or override the existing one.
|
/// or override the existing one.
|
||||||
final TextSpanDecoratorForAttribute? textSpanDecoratorForCustomAttributes;
|
final TextSpanDecoratorForAttribute? textSpanDecoratorForCustomAttributes;
|
||||||
|
|
||||||
|
/// customize the text span overlay builder
|
||||||
|
///
|
||||||
|
/// You can use this to customize the text span overlay, for example, a hover menu in linked text.
|
||||||
|
final AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder;
|
||||||
|
|
||||||
final TextDirection textDirection;
|
final TextDirection textDirection;
|
||||||
|
|
||||||
final Color cursorColor;
|
final Color cursorColor;
|
||||||
|
|
@ -115,12 +128,23 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
|
||||||
TextStyleConfiguration get textStyleConfiguration =>
|
TextStyleConfiguration get textStyleConfiguration =>
|
||||||
widget.editorState.editorStyle.textStyleConfiguration;
|
widget.editorState.editorStyle.textStyleConfiguration;
|
||||||
|
|
||||||
|
AppFlowyTextSpanOverlayBuilder? get textSpanOverlayBuilder =>
|
||||||
|
widget.textSpanOverlayBuilder ??
|
||||||
|
widget.editorState.editorStyle.textSpanOverlayBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
confirmContextEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget child = Stack(
|
Widget child = Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildPlaceholderText(context),
|
_buildPlaceholderText(context),
|
||||||
_buildRichText(context),
|
_buildRichText(context),
|
||||||
|
..._buildRichTextOverlay(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -410,6 +434,26 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildRichTextOverlay(BuildContext context) {
|
||||||
|
if (textKey.currentContext == null) return [];
|
||||||
|
return textSpanOverlayBuilder?.call(
|
||||||
|
context,
|
||||||
|
widget.node,
|
||||||
|
this,
|
||||||
|
) ??
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirmContextEnabled() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && textKey.currentContext == null) {
|
||||||
|
confirmContextEnabled();
|
||||||
|
} else if (mounted && textKey.currentContext != null) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAutoCompleteRichText() {
|
Widget _buildAutoCompleteRichText() {
|
||||||
final textInserts = widget.node.delta!.whereType<TextInsert>();
|
final textInserts = widget.node.delta!.whereType<TextInsert>();
|
||||||
TextSpan textSpan = getTextSpan(textInserts: textInserts);
|
TextSpan textSpan = getTextSpan(textInserts: textInserts);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ TextSpan defaultTextSpanDecoratorForAttribute(
|
||||||
// add a tap gesture recognizer to the text span
|
// add a tap gesture recognizer to the text span
|
||||||
Timer? timer;
|
Timer? timer;
|
||||||
int tapCount = 0;
|
int tapCount = 0;
|
||||||
|
|
||||||
final tapGestureRecognizer = TapGestureRecognizer()
|
final tapGestureRecognizer = TapGestureRecognizer()
|
||||||
..onTap = () async {
|
..onTap = () async {
|
||||||
// implement a simple double tap logic
|
// implement a simple double tap logic
|
||||||
|
|
@ -56,10 +57,12 @@ TextSpan defaultTextSpanDecoratorForAttribute(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return TextSpan(
|
return TextSpan(
|
||||||
style: before.style,
|
style: before.style,
|
||||||
text: text.text,
|
text: text.text,
|
||||||
recognizer: tapGestureRecognizer,
|
recognizer: tapGestureRecognizer,
|
||||||
|
mouseCursor: SystemMouseCursors.click,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return before;
|
return before;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class EditorStyle {
|
||||||
required this.selectionColor,
|
required this.selectionColor,
|
||||||
required this.textStyleConfiguration,
|
required this.textStyleConfiguration,
|
||||||
required this.textSpanDecorator,
|
required this.textSpanDecorator,
|
||||||
|
this.textSpanOverlayBuilder,
|
||||||
this.magnifierSize = const Size(72, 48),
|
this.magnifierSize = const Size(72, 48),
|
||||||
this.mobileDragHandleBallSize = const Size(8, 8),
|
this.mobileDragHandleBallSize = const Size(8, 8),
|
||||||
this.mobileDragHandleWidth = 2.0,
|
this.mobileDragHandleWidth = 2.0,
|
||||||
|
|
@ -64,6 +65,9 @@ class EditorStyle {
|
||||||
// or override the built-in text span.
|
// or override the built-in text span.
|
||||||
final TextSpanDecoratorForAttribute? textSpanDecorator;
|
final TextSpanDecoratorForAttribute? textSpanDecorator;
|
||||||
|
|
||||||
|
/// Customize the text span overlay builder.
|
||||||
|
final AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder;
|
||||||
|
|
||||||
final String? defaultTextDirection;
|
final String? defaultTextDirection;
|
||||||
|
|
||||||
// The size of the magnifier.
|
// The size of the magnifier.
|
||||||
|
|
@ -107,6 +111,7 @@ class EditorStyle {
|
||||||
Color? selectionColor,
|
Color? selectionColor,
|
||||||
TextStyleConfiguration? textStyleConfiguration,
|
TextStyleConfiguration? textStyleConfiguration,
|
||||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||||
|
this.textSpanOverlayBuilder,
|
||||||
this.defaultTextDirection,
|
this.defaultTextDirection,
|
||||||
this.cursorWidth = 2.0,
|
this.cursorWidth = 2.0,
|
||||||
this.textScaleFactor = 1.0,
|
this.textScaleFactor = 1.0,
|
||||||
|
|
@ -139,6 +144,7 @@ class EditorStyle {
|
||||||
Color? selectionColor,
|
Color? selectionColor,
|
||||||
TextStyleConfiguration? textStyleConfiguration,
|
TextStyleConfiguration? textStyleConfiguration,
|
||||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||||
|
this.textSpanOverlayBuilder,
|
||||||
this.defaultTextDirection,
|
this.defaultTextDirection,
|
||||||
this.magnifierSize = const Size(72, 48),
|
this.magnifierSize = const Size(72, 48),
|
||||||
this.mobileDragHandleBallSize = const Size(8, 8),
|
this.mobileDragHandleBallSize = const Size(8, 8),
|
||||||
|
|
@ -171,6 +177,7 @@ class EditorStyle {
|
||||||
Color? selectionColor,
|
Color? selectionColor,
|
||||||
TextStyleConfiguration? textStyleConfiguration,
|
TextStyleConfiguration? textStyleConfiguration,
|
||||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||||
|
AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder,
|
||||||
String? defaultTextDirection,
|
String? defaultTextDirection,
|
||||||
Size? magnifierSize,
|
Size? magnifierSize,
|
||||||
Size? mobileDragHandleBallSize,
|
Size? mobileDragHandleBallSize,
|
||||||
|
|
@ -193,6 +200,8 @@ class EditorStyle {
|
||||||
textStyleConfiguration:
|
textStyleConfiguration:
|
||||||
textStyleConfiguration ?? this.textStyleConfiguration,
|
textStyleConfiguration ?? this.textStyleConfiguration,
|
||||||
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
|
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
|
||||||
|
textSpanOverlayBuilder:
|
||||||
|
textSpanOverlayBuilder ?? this.textSpanOverlayBuilder,
|
||||||
defaultTextDirection: defaultTextDirection,
|
defaultTextDirection: defaultTextDirection,
|
||||||
magnifierSize: magnifierSize ?? this.magnifierSize,
|
magnifierSize: magnifierSize ?? this.magnifierSize,
|
||||||
mobileDragHandleBallSize:
|
mobileDragHandleBallSize:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ class FloatingToolbarStyle {
|
||||||
final double toolbarElevation;
|
final double toolbarElevation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef FloatingToolbarBuilder = Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
Widget child,
|
||||||
|
VoidCallback onDismiss,
|
||||||
|
);
|
||||||
|
|
||||||
/// A floating toolbar that displays at the top of the editor when the selection
|
/// A floating toolbar that displays at the top of the editor when the selection
|
||||||
/// and will be hidden when the selection is collapsed.
|
/// and will be hidden when the selection is collapsed.
|
||||||
///
|
///
|
||||||
|
|
@ -50,7 +56,7 @@ class FloatingToolbar extends StatefulWidget {
|
||||||
final EdgeInsets? padding;
|
final EdgeInsets? padding;
|
||||||
final Decoration? decoration;
|
final Decoration? decoration;
|
||||||
final PlaceHolderItemBuilder? placeHolderBuilder;
|
final PlaceHolderItemBuilder? placeHolderBuilder;
|
||||||
final ToolbarBuilder? toolbarBuilder;
|
final FloatingToolbarBuilder? toolbarBuilder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FloatingToolbar> createState() => _FloatingToolbarState();
|
State<FloatingToolbar> createState() => _FloatingToolbarState();
|
||||||
|
|
@ -206,7 +212,7 @@ class _FloatingToolbarState extends State<FloatingToolbar>
|
||||||
_toolbarContainer = OverlayEntry(
|
_toolbarContainer = OverlayEntry(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final child = _buildToolbar(context);
|
final child = _buildToolbar(context);
|
||||||
return widget.toolbarBuilder?.call(context, child) ??
|
return widget.toolbarBuilder?.call(context, child, _clear) ??
|
||||||
Positioned(
|
Positioned(
|
||||||
top: max(0, top) - floatingToolbarHeight,
|
top: max(0, top) - floatingToolbarHeight,
|
||||||
left: left,
|
left: left,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue