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:
Morn 2025-03-24 13:45:01 +08:00 • committed by GitHub
commit 5bcc4e8fd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 3 deletions

View file

@ -41,7 +41,7 @@
"delta": [
{
"insert": "Customizable",
"attributes": { "bg_color": "0x4d00BCF0" }
"attributes": { "href": "https://appflowy.io" }
}
]
}

View file

@ -129,9 +129,63 @@ class _DesktopEditorState extends State<DesktopEditor> {
),
padding: const EdgeInsets.symmetric(horizontal: 40),
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
Map<String, BlockComponentBuilder> _buildBlockComponentBuilders() {
final map = {
@ -273,3 +327,69 @@ class _DesktopEditorState extends State<DesktopEditor> {
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),
),
),
);
}
}

View file

@ -22,6 +22,12 @@ typedef AppFlowyAutoCompleteTextProvider = String? Function(
TextSpan? textSpan,
);
typedef AppFlowyTextSpanOverlayBuilder = List<Widget> Function(
BuildContext context,
Node node,
SelectableMixin delegate,
);
class AppFlowyRichText extends StatefulWidget {
const AppFlowyRichText({
super.key,
@ -33,6 +39,7 @@ class AppFlowyRichText extends StatefulWidget {
this.placeholderTextSpanDecorator,
this.textDirection = TextDirection.ltr,
this.textSpanDecoratorForCustomAttributes,
this.textSpanOverlayBuilder,
this.textAlign,
this.cursorColor = const Color.fromARGB(255, 0, 0, 0),
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
/// or override the existing one.
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 Color cursorColor;
@ -115,12 +128,23 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
TextStyleConfiguration get textStyleConfiguration =>
widget.editorState.editorStyle.textStyleConfiguration;
AppFlowyTextSpanOverlayBuilder? get textSpanOverlayBuilder =>
widget.textSpanOverlayBuilder ??
widget.editorState.editorStyle.textSpanOverlayBuilder;
@override
void initState() {
super.initState();
confirmContextEnabled();
}
@override
Widget build(BuildContext context) {
Widget child = Stack(
children: [
_buildPlaceholderText(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() {
final textInserts = widget.node.delta!.whereType<TextInsert>();
TextSpan textSpan = getTextSpan(textInserts: textInserts);

View file

@ -26,6 +26,7 @@ TextSpan defaultTextSpanDecoratorForAttribute(
// add a tap gesture recognizer to the text span
Timer? timer;
int tapCount = 0;
final tapGestureRecognizer = TapGestureRecognizer()
..onTap = () async {
// implement a simple double tap logic
@ -56,10 +57,12 @@ TextSpan defaultTextSpanDecoratorForAttribute(
});
});
};
return TextSpan(
style: before.style,
text: text.text,
recognizer: tapGestureRecognizer,
mouseCursor: SystemMouseCursors.click,
);
}
return before;

View file

@ -14,6 +14,7 @@ class EditorStyle {
required this.selectionColor,
required this.textStyleConfiguration,
required this.textSpanDecorator,
this.textSpanOverlayBuilder,
this.magnifierSize = const Size(72, 48),
this.mobileDragHandleBallSize = const Size(8, 8),
this.mobileDragHandleWidth = 2.0,
@ -64,6 +65,9 @@ class EditorStyle {
// or override the built-in text span.
final TextSpanDecoratorForAttribute? textSpanDecorator;
/// Customize the text span overlay builder.
final AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder;
final String? defaultTextDirection;
// The size of the magnifier.
@ -107,6 +111,7 @@ class EditorStyle {
Color? selectionColor,
TextStyleConfiguration? textStyleConfiguration,
TextSpanDecoratorForAttribute? textSpanDecorator,
this.textSpanOverlayBuilder,
this.defaultTextDirection,
this.cursorWidth = 2.0,
this.textScaleFactor = 1.0,
@ -139,6 +144,7 @@ class EditorStyle {
Color? selectionColor,
TextStyleConfiguration? textStyleConfiguration,
TextSpanDecoratorForAttribute? textSpanDecorator,
this.textSpanOverlayBuilder,
this.defaultTextDirection,
this.magnifierSize = const Size(72, 48),
this.mobileDragHandleBallSize = const Size(8, 8),
@ -171,6 +177,7 @@ class EditorStyle {
Color? selectionColor,
TextStyleConfiguration? textStyleConfiguration,
TextSpanDecoratorForAttribute? textSpanDecorator,
AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder,
String? defaultTextDirection,
Size? magnifierSize,
Size? mobileDragHandleBallSize,
@ -193,6 +200,8 @@ class EditorStyle {
textStyleConfiguration:
textStyleConfiguration ?? this.textStyleConfiguration,
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
textSpanOverlayBuilder:
textSpanOverlayBuilder ?? this.textSpanOverlayBuilder,
defaultTextDirection: defaultTextDirection,
magnifierSize: magnifierSize ?? this.magnifierSize,
mobileDragHandleBallSize:

View file

@ -19,6 +19,12 @@ class FloatingToolbarStyle {
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
/// and will be hidden when the selection is collapsed.
///
@ -50,7 +56,7 @@ class FloatingToolbar extends StatefulWidget {
final EdgeInsets? padding;
final Decoration? decoration;
final PlaceHolderItemBuilder? placeHolderBuilder;
final ToolbarBuilder? toolbarBuilder;
final FloatingToolbarBuilder? toolbarBuilder;
@override
State<FloatingToolbar> createState() => _FloatingToolbarState();
@ -206,7 +212,7 @@ class _FloatingToolbarState extends State<FloatingToolbar>
_toolbarContainer = OverlayEntry(
builder: (context) {
final child = _buildToolbar(context);
return widget.toolbarBuilder?.call(context, child) ??
return widget.toolbarBuilder?.call(context, child, _clear) ??
Positioned(
top: max(0, top) - floatingToolbarHeight,
left: left,