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": [
|
||||
{
|
||||
"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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue