Skip to content

Commit

Permalink
Fix toolbar selector positioning (#329)
Browse files Browse the repository at this point in the history
  • Loading branch information
Amir-P authored Apr 28, 2024
1 parent 3d8d7a5 commit 4f97476
Show file tree
Hide file tree
Showing 2 changed files with 235 additions and 59 deletions.
226 changes: 173 additions & 53 deletions packages/fleather/lib/src/widgets/editor_toolbar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -406,12 +406,10 @@ class _ColorButtonState extends State<ColorButton> {
};
final maxWidth = isMobile ? 200.0 : 100.0;

final renderBox = context.findRenderObject() as RenderBox;
final offset = renderBox.localToGlobal(Offset.zero) + Offset(0, buttonSize);

final completer = Completer<Color?>();

final selector = Material(
key: const Key('color_selector'),
elevation: 4.0,
color: Theme.of(context).canvasColor,
child: Container(
Expand All @@ -421,19 +419,7 @@ class _ColorButtonState extends State<ColorButton> {
onSelectedColor: completer.complete)),
);

return SelectorScope.of(context).pushSelector(
Stack(
children: [
Positioned(
key: const Key('color_palette'),
top: offset.dy,
left: offset.dx,
child: selector,
)
],
),
completer,
);
return SelectorScope.showSelector(context, selector, completer);
}

@override
Expand Down Expand Up @@ -648,32 +634,19 @@ class _SelectHeadingButtonState extends State<SelectHeadingButton> {
}

Future<ParchmentAttribute<int>?> _selectHeading() async {
final renderBox = context.findRenderObject() as RenderBox;
final offset =
renderBox.localToGlobal(Offset.zero) + Offset(0, buttonHeight);
final themeData = FleatherTheme.of(context)!;

final completer = Completer<ParchmentAttribute<int>?>();

final selector = Material(
key: const Key('heading_selector'),
elevation: 4.0,
borderRadius: BorderRadius.circular(2),
color: Theme.of(context).canvasColor,
child: _HeadingList(theme: themeData, onSelected: completer.complete),
);

return SelectorScope.of(context).pushSelector(
Stack(
children: [
Positioned(
top: offset.dy,
left: offset.dx,
child: selector,
)
],
),
completer,
);
return SelectorScope.showSelector(context, selector, completer);
}
}

Expand Down Expand Up @@ -1228,12 +1201,19 @@ class FLIconButton extends StatelessWidget {
}

class SelectorScope extends StatefulWidget {
final Widget child;

const SelectorScope({super.key, required this.child});

static SelectorScopeState of(BuildContext context) =>
context.findAncestorStateOfType<SelectorScopeState>()!;

final Widget child;
/// The [context] should belong to the presenter widget.
static Future<T?> showSelector<T>(
BuildContext context, Widget selector, Completer<T?> completer,
{bool rootOverlay = false}) =>
SelectorScope.of(context)
.showSelector(context, selector, completer, rootOverlay: rootOverlay);

@override
State<SelectorScope> createState() => SelectorScopeState();
Expand All @@ -1242,42 +1222,65 @@ class SelectorScope extends StatefulWidget {
class SelectorScopeState extends State<SelectorScope> {
OverlayEntry? _overlayEntry;

Future<T?> pushSelector<T>(Widget selector, Completer<T?> completer) {
/// The [context] should belong to the presenter widget.
Future<T?> showSelector<T>(
BuildContext context, Widget selector, Completer<T?> completer,
{bool rootOverlay = false}) {
_overlayEntry?.remove();
final overlayEntry = OverlayEntry(
builder: (context) => TapRegion(
child: selector,
onTapOutside: (_) => completer.complete(null),

final overlay = Overlay.of(context, rootOverlay: rootOverlay);

final RenderBox presenter = context.findRenderObject() as RenderBox;
final RenderBox overlayBox =
overlay.context.findRenderObject() as RenderBox;
final offset = Offset(0.0, presenter.size.height);
final position = RelativeRect.fromSize(
Rect.fromPoints(
presenter.localToGlobal(offset, ancestor: overlayBox),
presenter.localToGlobal(
presenter.size.bottomRight(Offset.zero) + offset,
ancestor: overlayBox,
),
),
overlayBox.size,
);
overlayEntry.addListener(() {
if (!overlayEntry.mounted && !completer.isCompleted) {
completer.complete(null);
}
});
completer.future.whenComplete(() {
overlayEntry.remove();
if (_overlayEntry == overlayEntry) {

final mediaQueryData = MediaQuery.of(context);
final textDirection = Directionality.of(context);

_overlayEntry = OverlayEntry(
builder: (context) => CustomSingleChildLayout(
delegate: _SelectorLayout(
position,
textDirection,
mediaQueryData.padding,
DisplayFeatureSubScreen.avoidBounds(mediaQueryData).toSet(),
),
child: TapRegion(
child: selector,
onTapOutside: (_) => completer.complete(null),
),
),
);
_overlayEntry?.addListener(() {
if (_overlayEntry?.mounted != true && !completer.isCompleted) {
_overlayEntry?.dispose();
_overlayEntry = null;
completer.complete(null);
}
});
_overlayEntry = overlayEntry;
Overlay.of(context).insert(_overlayEntry!);
completer.future.whenComplete(removeEntry);
overlay.insert(_overlayEntry!);
return completer.future;
}

void removeEntry() {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry!.dispose();
_overlayEntry = null;
}

@override
void didUpdateWidget(covariant SelectorScope oldWidget) {
super.didUpdateWidget(oldWidget);
removeEntry();
}

@override
void dispose() {
super.dispose();
Expand All @@ -1289,3 +1292,120 @@ class SelectorScopeState extends State<SelectorScope> {
return widget.child;
}
}

const _selectorScreenPadding = 8.0;

// This is a clone of _PopupMenuRouteLayout from Flutter with some modifications
class _SelectorLayout extends SingleChildLayoutDelegate {
_SelectorLayout(
this.position,
this.textDirection,
this.padding,
this.avoidBounds,
);

// Rectangle of underlying button, relative to the overlay's dimensions.
final RelativeRect position;

// Whether to prefer going to the left or to the right.
final TextDirection textDirection;

// The padding of unsafe area.
EdgeInsets padding;

// List of rectangles that we should avoid overlapping. Unusable screen area.
final Set<Rect> avoidBounds;

// We put the child wherever position specifies, so long as it will fit within
// the specified parent size padded (inset) by [_selectorScreenPadding].
// If necessary, we adjust the child's position so that it fits.

@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
// The menu can be at most the size of the overlay minus 8.0 pixels in each
// direction.
return BoxConstraints.loose(constraints.biggest).deflate(
const EdgeInsets.all(_selectorScreenPadding) + padding,
);
}

@override
Offset getPositionForChild(Size size, Size childSize) {
// size: The size of the overlay.
// childSize: The size of the menu, when fully open, as determined by
// getConstraintsForChild.

final double y = position.top;

// Find the ideal horizontal position.
double x;
if (position.right > childSize.width) {
// Menu button is closer to the left edge, so grow to the right, aligned to the left edge.
x = position.left;
} else if (position.left > childSize.width) {
// Menu button is closer to the right edge, so grow to the left, aligned to the right edge.
x = size.width - position.right - childSize.width;
} else {
switch (textDirection) {
case TextDirection.rtl:
x = size.width - position.right - childSize.width;
case TextDirection.ltr:
x = position.left;
}
}

final Offset wantedPosition = Offset(x, y);
final Offset originCenter = position.toRect(Offset.zero & size).center;
final Iterable<Rect> subScreens =
DisplayFeatureSubScreen.subScreensInBounds(
Offset.zero & size, avoidBounds);
final Rect subScreen = _closestScreen(subScreens, originCenter);
return _fitInsideScreen(subScreen, childSize, wantedPosition);
}

Rect _closestScreen(Iterable<Rect> screens, Offset point) {
Rect closest = screens.first;
for (final Rect screen in screens) {
if ((screen.center - point).distance <
(closest.center - point).distance) {
closest = screen;
}
}
return closest;
}

Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) {
double x = wantedPosition.dx;
double y = wantedPosition.dy;
// Avoid going outside an area defined as the rectangle 8.0 pixels from the
// edge of the screen in every direction.
if (x < screen.left + _selectorScreenPadding + padding.left) {
x = screen.left + _selectorScreenPadding + padding.left;
} else if (x + childSize.width >
screen.right - _selectorScreenPadding - padding.right) {
x = screen.right -
childSize.width -
_selectorScreenPadding -
padding.right;
}
if (y < screen.top + _selectorScreenPadding + padding.top) {
y = _selectorScreenPadding + padding.top;
} else if (y + childSize.height >
screen.bottom - _selectorScreenPadding - padding.bottom) {
y = screen.bottom -
childSize.height -
_selectorScreenPadding -
padding.bottom;
}

return Offset(x, y);
}

@override
bool shouldRelayout(_SelectorLayout oldDelegate) {
return position != oldDelegate.position ||
textDirection != oldDelegate.textDirection ||
padding != oldDelegate.padding ||
!setEquals(avoidBounds, oldDelegate.avoidBounds);
}
}
Loading

0 comments on commit 4f97476

Please sign in to comment.