-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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
Hide balloon panel while scrolling out of the limiter element. #14528
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as off-topic.
This comment was marked as off-topic.
Horizontal scrolling issue is out of the scope of this. Follow up can be found here. |
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 |
…miter-element-while-scrolling
…miter-element-while-scrolling
There was a problem hiding this 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 despitegetOptimalPosition()
working as a generic helper (also used byDropdownView
) 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> { |
There was a problem hiding this comment.
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
ckeditor5/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts
Lines 395 to 407 in fece664
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; | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Problably here too
ckeditor5/packages/ckeditor5-ui/src/editorui/poweredby.ts
Lines 350 to 362 in fece664
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; | |
} | |
} | |
} | |
If we don't want all ancestors right away, this helper could be rewritten to generator/iterator protol.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: () => { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 ) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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`. |
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
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! ); |
There was a problem hiding this comment.
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
ckeditor5/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts
Lines 417 to 429 in fece664
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; |
There was a problem hiding this comment.
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.
Different approach was chosen - more generic one 👉 #14630 |
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 testing⏳cksource/qa-internal#176:green_circle: :point_right: #14528 (comment)