Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hide balloon panel while scrolling out of the limiter element. #14528

Conversation

pszczesniak
Copy link
Contributor

@pszczesniak pszczesniak commented Jul 4, 2023

Suggested merge commit message (convention)

Fix (ui): Should hide the ballon panel when it's scrolled outside the visible area.

Closes #5328.


Additional information

Waiting for QA testingcksource/qa-internal#176  :green_circle: :point_right: #14528 (comment)

@charlttsie

This comment was marked as resolved.

@charlttsie

This comment was marked as resolved.

@charlttsie

This comment was marked as resolved.

@mabryl

This comment was marked as off-topic.

@pszczesniak
Copy link
Contributor Author

Horizontal scrolling issue is out of the scope of this.

Follow up can be found here.

@mabryl
Copy link
Contributor

mabryl commented Jul 17, 2023

QA testing completed and it looks good after the latest round of testing. Summary here: https://cksource.slack.com/archives/C056P7FQ8TY/p1689603459626639?thread_ts=1688474081.871299&cid=C056P7FQ8TY

@Dumluregn Dumluregn requested a review from oleq July 20, 2023 08:04
Copy link
Member

@oleq oleq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your solution

  • you added a hiding position to the BalloonPanelView#attachTo() defaults,
  • you added a hiding position to the list in widget and image utils responsible for positioning toolbars
  • you assumed in getOptimalPosition() that the last available position is the hiding one despite getOptimalPosition() working as a generic helper (also used by DropdownView) and having an open API with the list of positions (also custom ones) to be passed by developers.

This will fail as soon as somebody configures any balloon (or other UI using getOptimalPosition()) with their custom positions and they won't put the hiding one as the last. getOptimalPosition() will pick the last one in the corner cases and instead of getting hidden, the balloon (UI) will move in some unexpected fashion.


The idea that comes to my mind is to have getOptimalPosition() return null when the target becomes invisible (that ancestor rect intersection check that you did). Then:

  • BalloonPanelView could say "oh, alright then, I'm gonna use my hidden position in this case because balloons cannot be attached to a void"
  • DropdownView could say "oh, alright then, I'm gonna use my default position (let's say its south) because you just can't make a dropdown panel disappear like a balloon"
  • Integrators UI could say "oh, I'm gonna handle it on my own but the API does not force me to do anything specific, it just gives me options (they hidden position is not hardcoded anywhere)"

* @param element
* @returns an array of scrollable ancestors.
*/
export default function getScrollableAncestors( element: HTMLElement ): Array<HTMLElement | Document> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be used in

function _getScrollableAncestors( element: HTMLElement ) {
const scrollableAncestors = [];
let scrollableAncestor = findClosestScrollableAncestor( element );
while ( scrollableAncestor && scrollableAncestor !== global.document.body ) {
scrollableAncestors.push( scrollableAncestor );
scrollableAncestor = findClosestScrollableAncestor( scrollableAncestor! );
}
scrollableAncestors.push( global.document );
return scrollableAncestors;
}
right away if implementations do match.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problably here too

else {
const firstScrollableEditableElementAncestor = findClosestScrollableAncestor( focusedEditableElement );
if ( firstScrollableEditableElementAncestor ) {
const firstScrollableEditableElementAncestorRect = new Rect( firstScrollableEditableElementAncestor );
// The watermark cannot be positioned in this corner because the corner is "not visible enough".
if ( visibleEditableElementRect.bottom + balloonRect.height / 2 > firstScrollableEditableElementAncestorRect.bottom ) {
return OFF_THE_SCREEN_POSITION;
}
}
}
. AFAIR Illia was changing something there recently so let's see if it makes sense.

If we don't want all ancestors right away, this helper could be rewritten to generator/iterator protol.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is missing unit tests.

@@ -54,7 +54,8 @@ export function getBalloonPositionData( editor: Editor ): Partial<PositionOption
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
defaultPositions.southArrowNorthEast,
defaultPositions.viewportStickyNorth
defaultPositions.viewportStickyNorth,
defaultPositions.viewportHidden
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This declarative approach got me worried.

On one hand, it allows integrators to opt-out of the viewportHidden position if they don't want it for some reason.

On the other hand, no such use-case pops into my head. And that would need to be a use-case where the balloon should stay visible despite its target being obscured by scrollable ancestors or just off the viewport.

Maybe if there's no such use-case, this should become a core behavior of getOptimalPosition() (a last-resort position injected to positions passed by the user)? Or even better, a core auto-injected position in BalloonPanelView#attachTo() because getOptimalPosition() is also used by other pieces of UI (e.g. some dropdowns) and making dropdown panel disappear is out of question.


// ------- Hidden

viewportHidden: () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names of positions were written in such a way that they describe where the balloon will show up. For instance eastArrowWest means the balloon will show up east of its target with the arrow poining west. viewportStickyNorth means that the balloon will be sticky to the north of the viewport. viewportHidden then means...? I think plain hidden would make more sense here.

@@ -245,7 +248,8 @@ export default class BalloonPanelView extends View {
defaultPositions.northArrowSouthMiddleEast,
defaultPositions.northArrowSouthWest,
defaultPositions.northArrowSouthEast,
defaultPositions.viewportStickyNorth
defaultPositions.viewportStickyNorth,
defaultPositions.viewportHidden
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment in image/ui/utils.ts. I would try injecting this position in L257 whether there were no positions specified by the user (defaults being used) or they used some specific ones (even custom ones).

.reverse()
.map( item => new Rect( item as HTMLElement ) )
.reduce( ( ancestorIntersectionAccumulator, ancestorRect ): Rect => {
if ( !ancestorIntersectionAccumulator || ancestorIntersectionAccumulator.top === undefined ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would top be undefined? Which use-case is it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this to cover cases when getIntersection() returns null that you ignore by casting as Rect in the last line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example:

new Rect( global.document )
------
Rect {top: undefined, right: undefined, bottom: undefined, left: undefined, width: undefined, height: undefined}

// @if CK_DEBUG_POSITION // RectDrawer.clear();
// @if CK_DEBUG_POSITION // RectDrawer.draw( targetRect, { outlineWidth: '5px' }, 'Target' );

const viewportRect = fitInViewport && getConstrainedViewportRect( viewportOffsetConfig ) || null;
const positionOptions = { targetRect, elementRect, positionedElementAncestor, viewportRect };

if ( ( !ancestorsIntersection ) || ( !targetRect.getVisible() ) ) {
// Default last position is `viewportHidden`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't know that. getOptimalPosition() gets an arbitrary set of positions. It is not used by balloon system only but also by dropdowns to position their panels.

// @if CK_DEBUG_POSITION // RectDrawer.clear();
// @if CK_DEBUG_POSITION // RectDrawer.draw( targetRect, { outlineWidth: '5px' }, 'Target' );

const viewportRect = fitInViewport && getConstrainedViewportRect( viewportOffsetConfig ) || null;
const positionOptions = { targetRect, elementRect, positionedElementAncestor, viewportRect };

if ( ( !ancestorsIntersection ) || ( !targetRect.getVisible() ) ) {
// Default last position is `viewportHidden`.
return new PositionObject( positions.at( -1 )!, positionOptions );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at() is supported in Safari since Mar 2022 https://caniuse.com/?search=Array.at. Knowing yearly release cycle of MacOS I'm wondering how many "old" installations of MacOS are still in use and how often would this throw. Let's stay on the safe side for a little longer, WDYT?

// Default last position is `viewportHidden`.
return new PositionObject( positions.at( -1 )!, positionOptions );
}
const ancestorsIntersectionWindowRect = new Rect( window ).getIntersection( ancestorsIntersection! );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get scrollable ancestors will always consider global.document.

scrollableAncestors.push( global.document );

If you got it covered while retrieving rects, you wouldn't need to handle this one separately. It would be just another rect to intersect. See the implementation of the sticky panel to learn more

const scrollableAncestorsRects = scrollableAncestors.map( ancestor => {
// The document (window) is yet another scrollable ancestor, but cropped by the top offset.
if ( ancestor instanceof Document ) {
const windowRect = new Rect( global.window );
windowRect.top += viewportTopOffset;
windowRect.height -= viewportTopOffset;
return windowRect;
} else {
return new Rect( ancestor );
}
} );


// When `limiter` is not set and `getBestPosition` return null lets return the first `position`
// from the `positions` list. If the limiter is set - we will take the last `position` (hide).
const indexOfPositionToReturn = limiter ? -1 : 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like I said before, an assumption that the last available position is the one that hides the positioned element is weak.

@pszczesniak
Copy link
Contributor Author

Different approach was chosen - more generic one 👉 #14630

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Balloon panel sticks out of the limiter element while scrolling
4 participants