Line data Source code
1 : import * as React from 'react';
2 : import { observeSize } from './size_observer.js';
3 : import { isDesktopHost } from '../../platform/native_globals.js';
4 :
5 1 : export enum AnchorAlignment {
6 1 : Start,
7 1 : Center,
8 1 : End
9 1 : }
10 :
11 1 : export enum AnchorSide {
12 1 : InsideTop = 0 << 1,
13 1 : InsideBottom = 1 << 1,
14 1 : InsideLeft = 2 << 1,
15 1 : InsideRight = 3 << 1,
16 :
17 1 : InsideCenter = 4 << 1,
18 :
19 1 : OutsideTop = (5 << 1) | 0b1,
20 1 : OutsideBottom = (6 << 1) | 0b1,
21 1 : OutsideLeft = (7 << 1) | 0b1,
22 1 : OutsideRight = (8 << 1) | 0b1,
23 1 : }
24 :
25 1 : function isInsideAnchorSide(side: AnchorSide) { return (side & 0b1) == 0; }
26 :
27 : export interface PositionSettings {
28 : /// Sets the side of the anchor element that the floating element should be
29 : /// pinned to. This side is given by a string starting with either "inside" or
30 : /// "outside", followed by a hyphen, followed by either "top", "right", "bottom",
31 : /// or "left". Additionally, "inside-center" is an allowed value.
32 : ///
33 : /// The first part of this string, "inside" or "outside", determines whether the
34 : /// floating element should be attempted to be placed "inside" the anchor element
35 : /// or "outside" of it. Using "inside" is useful for making it appear that the
36 : /// anchor _contains_ the floating element, and it can be used for implementing a
37 : /// dialog that is centered on the screen. The "outside" value is more common and
38 : /// can be used for tooltips, popovers, menus, etc.
39 : ///
40 : /// The second part of this string determines the _edge_ on the anchor element that
41 : /// the floating element will be anchored to. If side is "inside-center", then
42 : /// the floating element will be centered in the X-direction (while align is used
43 : /// to position it in the Y-direction).
44 : /// Note: "outside-center" is _not_ a valid value for this property.
45 : side: AnchorSide;
46 : /// Determines how the floating element should align with the anchor element. If
47 : /// set to "start", the floating element's first edge (top or left) will align
48 : /// with the anchor element's first edge. If set to "center", the floating
49 : /// element will be centered along the axis of the anchor edge. If set to "end",
50 : /// the floating element's last edge will align with the anchor element's last edge.
51 : align: AnchorAlignment;
52 : /// The number of pixels between the anchor edge and the floating element.
53 : ///
54 : /// Positive values move the floating element farther from the anchor element
55 : /// (for outside positioning) or further inside the anchor element (for inside
56 : /// positioning). Negative values have the opposite effect.
57 : anchorOffset: number;
58 : /// An additional offset, in pixels, to move the floating element from
59 : /// the aligning edge.
60 : ///
61 : /// Positive values move the floating element in the direction of center-
62 : /// alignment. Negative values move the floating element away from center-
63 : /// alignment. When align is "center", positive offsets move the floating
64 : /// element right (top or bottom anchor side) or down (left or right
65 : /// anchor side).
66 : alignmentOffset: number;
67 : /// If false, when the above settings result in rendering the floating element
68 : /// wholly or partially outside of the bounds of the containing element, attempt
69 : /// to adjust the settings to prevent this. Only applies to "outside" positioning.
70 : ///
71 : /// First, attempt to flip to the opposite edge of the anchor if the floating
72 : /// element is getting clipped in that direction. If flipping results in a
73 : /// similar clipping, try moving to the adjacent sides.
74 : ///
75 : /// Once we find a side that does not clip the overlay in its own dimension,
76 : /// check the rest of the sides to see if we need to adjust the alignment offset
77 : /// to fit in other dimensions.
78 : ///
79 : /// If we try all four sides and get clipped each time, settle for overflowing
80 : /// and use the "bottom" side, since the ability to scroll is most likely in
81 : /// this direction.
82 : allowOutOfBounds: boolean;
83 : }
84 :
85 : /// For each outside anchor position, list the order of alternate positions to try in
86 : /// the event that the original position overflows. See comment on `allowOutOfBounds`
87 : /// for a more detailed description.
88 1 : const alternateOrders: Partial<Record<AnchorSide, [AnchorSide, AnchorSide, AnchorSide, AnchorSide]>> = {
89 1 : [AnchorSide.OutsideTop]: [AnchorSide.OutsideBottom, AnchorSide.OutsideRight, AnchorSide.OutsideLeft, AnchorSide.OutsideBottom],
90 1 : [AnchorSide.OutsideBottom]: [AnchorSide.OutsideTop, AnchorSide.OutsideRight, AnchorSide.OutsideLeft, AnchorSide.OutsideBottom],
91 1 : [AnchorSide.OutsideLeft]: [AnchorSide.OutsideRight, AnchorSide.OutsideBottom, AnchorSide.OutsideTop, AnchorSide.OutsideBottom],
92 1 : [AnchorSide.OutsideRight]: [AnchorSide.OutsideLeft, AnchorSide.OutsideBottom, AnchorSide.OutsideTop, AnchorSide.OutsideBottom],
93 1 : }
94 :
95 : /// For each alignment, list the order of alternate alignments to try in
96 : /// the event that the original position overflows.
97 : /// Prefer start or end over center.
98 1 : const alternateAlignments: Partial<Record<AnchorAlignment, [AnchorAlignment, AnchorAlignment]>> = {
99 1 : [AnchorAlignment.Start]: [AnchorAlignment.End, AnchorAlignment.Center],
100 1 : [AnchorAlignment.End]: [AnchorAlignment.Start, AnchorAlignment.Start],
101 1 : [AnchorAlignment.Center]: [AnchorAlignment.End, AnchorAlignment.Start],
102 1 : }
103 :
104 : interface Size {
105 : width: number
106 : height: number
107 : }
108 :
109 : interface Position {
110 : top: number
111 : left: number
112 : }
113 :
114 : export interface AnchorPosition {
115 : top: number
116 : left: number
117 : anchorSide: AnchorSide
118 : anchorAlign: AnchorAlignment
119 : }
120 :
121 : interface BoxPosition extends Size, Position { }
122 :
123 : /// Given a floating element and an anchor element, return coordinates for the top-left
124 : /// of the floating element in order to absolutely position it such that it appears
125 : /// near the anchor element.
126 1 : export function getAnchoredPosition(
127 1 : floatingElement: Element,
128 1 : anchorElement: Element | DOMRect,
129 1 : settings: Partial<PositionSettings> = {},
130 1 : ): AnchorPosition {
131 1 : const parentElement = getPositionedParent(floatingElement)
132 1 : const clippingRect = getClippingRect(parentElement)
133 :
134 1 : const parentElementStyle = getComputedStyle(parentElement)
135 1 : const parentElementRect = parentElement.getBoundingClientRect()
136 1 : const [borderTop, borderLeft] = [parentElementStyle.borderTopWidth, parentElementStyle.borderLeftWidth].map(
137 1 : v => parseInt(v, 10) || 0,
138 1 : )
139 : const relativeRect = {
140 1 : top: parentElementRect.top + borderTop,
141 1 : left: parentElementRect.left + borderLeft,
142 : }
143 :
144 1 : return pureCalculateAnchoredPosition(
145 1 : clippingRect,
146 1 : relativeRect,
147 1 : floatingElement.getBoundingClientRect(),
148 1 : anchorElement instanceof Element ? anchorElement.getBoundingClientRect() : anchorElement,
149 1 : getDefaultSettings(settings),
150 1 : )
151 1 : }
152 :
153 : /// Returns the nearest proper HTMLElement parent of `element` whose
154 : /// position is not "static", or document.body, whichever is closer
155 1 : function getPositionedParent(element: Element) {
156 1 : if (isOnTopLayer(element)) return document.body
157 1 : let parentNode = element.parentNode
158 1 : while (parentNode !== null) {
159 1 : if (parentNode instanceof HTMLElement && getComputedStyle(parentNode).position !== 'static') {
160 1 : return parentNode
161 : }
162 1 : parentNode = parentNode.parentNode
163 1 : }
164 1 : return document.body
165 1 : }
166 :
167 : /// Returns true if the element is likely to be on the `top-layer`.
168 1 : function isOnTopLayer(element: Element) {
169 1 : if (element.tagName === 'DIALOG') {
170 1 : return true
171 : }
172 1 : try {
173 : // eslint-disable-next-line @typescript-eslint/no-explicit-any
174 1 : if (element.matches(':popover-open') && /native code/.test((document.body as any).showPopover?.toString())) {
175 1 : return true
176 : }
177 1 : } catch {
178 1 : return false
179 1 : }
180 1 : return false
181 1 : }
182 :
183 : /// Returns the rectangle (relative to the window) that will clip the given element
184 : /// if it is rendered outside of its bounds.
185 1 : function getClippingRect(element: Element): BoxPosition {
186 1 : let parentNode: typeof element.parentNode = element
187 1 : while (parentNode !== null) {
188 1 : if (!(parentNode instanceof Element)) {
189 1 : break
190 : }
191 1 : const parentNodeStyle = getComputedStyle(parentNode as Element)
192 1 : if (parentNodeStyle.overflow !== 'visible') {
193 1 : break
194 : }
195 1 : parentNode = parentNode.parentNode
196 1 : }
197 1 : const clippingNode = parentNode === document.body || !(parentNode instanceof HTMLElement) ? document.body : parentNode
198 :
199 1 : const elemRect = clippingNode.getBoundingClientRect()
200 1 : const elemStyle = getComputedStyle(clippingNode)
201 :
202 1 : const [borderTop, borderLeft, borderRight, borderBottom] = [
203 1 : elemStyle.borderTopWidth,
204 1 : elemStyle.borderLeftWidth,
205 1 : elemStyle.borderRightWidth,
206 1 : elemStyle.borderBottomWidth,
207 1 : ].map(v => parseInt(v, 10) || 0)
208 :
209 1 : return {
210 1 : top: elemRect.top + borderTop,
211 1 : left: elemRect.left + borderLeft,
212 1 : width: elemRect.width - borderRight - borderLeft,
213 :
214 : // If the clipping node is document.body, it can expand to the full height of the window
215 1 : height: Math.max(
216 1 : elemRect.height - borderTop - borderBottom,
217 1 : clippingNode === document.body ? window.innerHeight : -Infinity,
218 1 : ),
219 1 : }
220 1 : }
221 :
222 : // Pixels of breathing room kept between an out-of-bounds overlay and the
223 : // window edges. Matches the max-width/max-height inset in overlay.module.css.
224 1 : const VIEWPORT_INSET = 16;
225 :
226 : // On macOS we run with a frameless window (titleBarStyle: "Overlay"), so the
227 : // webview spans the full window including the traffic-light region. Reserve
228 : // extra space at the top so anchored overlays don't underflow the controls.
229 1 : const MACOS_TITLE_BAR_INSET = 36;
230 1 : function getTopViewportInset(): number {
231 1 : return isDesktopHost() ? MACOS_TITLE_BAR_INSET : VIEWPORT_INSET;
232 1 : }
233 :
234 : // Default settings to position a floating element
235 1 : const positionDefaults: PositionSettings = {
236 1 : side: AnchorSide.OutsideBottom,
237 1 : align: AnchorAlignment.Start,
238 :
239 : // note: the following default is not applied if side === "inside-center"
240 1 : anchorOffset: 4,
241 :
242 : // note: the following default is only applied if side starts with "inside"
243 : // and align is not center
244 1 : alignmentOffset: 4,
245 :
246 1 : allowOutOfBounds: false,
247 1 : }
248 :
249 : /// Compute a full PositionSettings object from the given partial PositionSettings object
250 : /// by filling in with defaults where applicable.
251 1 : function getDefaultSettings(settings: Partial<PositionSettings> = {}): PositionSettings {
252 1 : const side = settings.side ?? positionDefaults.side
253 1 : const align = settings.align ?? positionDefaults.align
254 1 : return {
255 1 : side,
256 1 : align,
257 : // offsets always default to 0 if their respective side/alignment is centered
258 1 : anchorOffset: settings.anchorOffset ?? (side === AnchorSide.InsideCenter ? 0 : positionDefaults.anchorOffset),
259 1 : alignmentOffset:
260 1 : settings.alignmentOffset ??
261 1 : (align !== AnchorAlignment.Center && isInsideAnchorSide(side) ? positionDefaults.alignmentOffset : 0),
262 1 : allowOutOfBounds: settings.allowOutOfBounds ?? positionDefaults.allowOutOfBounds,
263 1 : }
264 1 : }
265 :
266 : /// Note: This is a pure function with no dependency on DOM APIs.
267 1 : function pureCalculateAnchoredPosition(
268 1 : viewportRect: BoxPosition,
269 1 : relativePosition: Position,
270 1 : floatingRect: Size,
271 1 : anchorRect: BoxPosition,
272 1 : { side, align, allowOutOfBounds, anchorOffset, alignmentOffset }: PositionSettings,
273 1 : ): AnchorPosition {
274 : // Compute the relative viewport rect, to bring it into the same coordinate space as `pos`
275 1 : const relativeViewportRect: BoxPosition = {
276 1 : top: viewportRect.top - relativePosition.top,
277 1 : left: viewportRect.left - relativePosition.left,
278 1 : width: viewportRect.width,
279 1 : height: viewportRect.height,
280 1 : }
281 :
282 1 : let pos = calculatePosition(floatingRect, anchorRect, side, align, anchorOffset, alignmentOffset)
283 1 : let anchorSide = side
284 1 : let anchorAlign = align
285 1 : pos.top -= relativePosition.top
286 1 : pos.left -= relativePosition.left
287 :
288 : // Handle screen overflow
289 1 : if (!allowOutOfBounds) {
290 1 : const alternateOrder = alternateOrders[side]
291 :
292 1 : let positionAttempt = 0
293 1 : if (alternateOrder) {
294 1 : let prevSide = side
295 :
296 : // Try all the alternate sides until one does not overflow
297 1 : while (
298 1 : positionAttempt < alternateOrder.length &&
299 1 : shouldRecalculatePosition(prevSide, pos, relativeViewportRect, floatingRect)
300 1 : ) {
301 1 : const nextSide = alternateOrder[positionAttempt++]
302 1 : prevSide = nextSide
303 :
304 : // If we have cut off in the same dimension as the "side" option, try flipping to the opposite side.
305 1 : pos = calculatePosition(floatingRect, anchorRect, nextSide, align, anchorOffset, alignmentOffset)
306 1 : pos.top -= relativePosition.top
307 1 : pos.left -= relativePosition.left
308 1 : anchorSide = nextSide
309 1 : }
310 1 : }
311 :
312 : // If using alternate anchor side does not stop overflow,
313 : // try using an alternate alignment
314 1 : const alternateAlignment = alternateAlignments[align]
315 :
316 1 : let alignmentAttempt = 0
317 1 : if (alternateAlignment) {
318 1 : let prevAlign = align
319 :
320 : // Try all the alternate alignments until one does not overflow
321 1 : while (
322 1 : alignmentAttempt < alternateAlignment.length &&
323 1 : shouldRecalculateAlignment(prevAlign, pos, relativeViewportRect, floatingRect)
324 1 : ) {
325 1 : const nextAlign = alternateAlignment[alignmentAttempt++]
326 1 : prevAlign = nextAlign
327 :
328 1 : pos = calculatePosition(floatingRect, anchorRect, anchorSide, nextAlign, anchorOffset, alignmentOffset)
329 1 : pos.top -= relativePosition.top
330 1 : pos.left -= relativePosition.left
331 1 : anchorAlign = nextAlign
332 1 : }
333 1 : }
334 :
335 : // At this point we've flipped the position if applicable. Now just nudge until it's on-screen,
336 : // keeping VIEWPORT_INSET pixels of breathing room from each window edge. The top inset is
337 : // larger on native macOS to clear the frameless-window title bar / traffic lights.
338 : // Clamp bottom *first* so the subsequent top clamp wins on overlays taller than the viewport
339 : // (we'd rather show the top of the content than the bottom).
340 1 : const topInset = getTopViewportInset();
341 1 : if (pos.top + floatingRect.height > viewportRect.height + relativeViewportRect.top - VIEWPORT_INSET) {
342 1 : pos.top = Math.max(viewportRect.height + relativeViewportRect.top - floatingRect.height - VIEWPORT_INSET, relativeViewportRect.top + topInset)
343 : }
344 1 : if (pos.top < relativeViewportRect.top + topInset) {
345 1 : pos.top = relativeViewportRect.top + topInset
346 : }
347 1 : if (pos.left < relativeViewportRect.left + VIEWPORT_INSET) {
348 1 : pos.left = relativeViewportRect.left + VIEWPORT_INSET
349 : }
350 1 : if (pos.left + floatingRect.width > viewportRect.width + relativeViewportRect.left - VIEWPORT_INSET) {
351 1 : pos.left = viewportRect.width + relativeViewportRect.left - floatingRect.width - VIEWPORT_INSET
352 : }
353 1 : }
354 :
355 1 : return { ...pos, anchorSide, anchorAlign }
356 1 : }
357 :
358 : /// Given a floating element and an anchor element, return coordinates for the
359 : /// top-left of the floating element in order to absolutely position it such
360 : /// that it appears near the anchor element.
361 1 : function calculatePosition(
362 1 : elementDimensions: Size,
363 1 : anchorPosition: BoxPosition,
364 1 : side: AnchorSide,
365 1 : align: AnchorAlignment,
366 1 : anchorOffset: number,
367 1 : alignmentOffset: number,
368 1 : ) {
369 1 : const anchorRight = anchorPosition.left + anchorPosition.width
370 1 : const anchorBottom = anchorPosition.top + anchorPosition.height
371 1 : let top = -1
372 1 : let left = -1
373 1 : if (side === AnchorSide.OutsideTop) {
374 1 : top = anchorPosition.top - anchorOffset - elementDimensions.height
375 1 : } else if (side === AnchorSide.OutsideBottom) {
376 1 : top = anchorBottom + anchorOffset
377 1 : } else if (side === AnchorSide.OutsideLeft) {
378 1 : left = anchorPosition.left - anchorOffset - elementDimensions.width
379 1 : } else if (side === AnchorSide.OutsideRight) {
380 1 : left = anchorRight + anchorOffset
381 : }
382 :
383 1 : if (side === AnchorSide.OutsideTop || side === AnchorSide.OutsideBottom) {
384 1 : if (align === AnchorAlignment.Start) {
385 1 : left = anchorPosition.left + alignmentOffset
386 1 : } else if (align === AnchorAlignment.Center) {
387 1 : left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset
388 : } else {
389 : // end
390 1 : left = anchorRight - elementDimensions.width - alignmentOffset
391 : }
392 1 : }
393 :
394 1 : if (side === AnchorSide.OutsideLeft || side === AnchorSide.OutsideRight) {
395 1 : if (align === AnchorAlignment.Start) {
396 1 : top = anchorPosition.top + alignmentOffset
397 1 : } else if (align === AnchorAlignment.Center) {
398 1 : top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset
399 : } else {
400 : // end
401 1 : top = anchorBottom - elementDimensions.height - alignmentOffset
402 : }
403 1 : }
404 :
405 1 : if (side === AnchorSide.InsideTop) {
406 1 : top = anchorPosition.top + anchorOffset
407 1 : } else if (side === AnchorSide.InsideBottom) {
408 1 : top = anchorBottom - anchorOffset - elementDimensions.height
409 1 : } else if (side === AnchorSide.InsideLeft) {
410 1 : left = anchorPosition.left + anchorOffset
411 1 : } else if (side === AnchorSide.InsideRight) {
412 1 : left = anchorRight - anchorOffset - elementDimensions.width
413 1 : } else if (side === AnchorSide.InsideCenter) {
414 1 : left = (anchorRight + anchorPosition.left) / 2 - elementDimensions.width / 2 + anchorOffset
415 : }
416 :
417 1 : if (side === AnchorSide.InsideTop || side === AnchorSide.InsideBottom) {
418 1 : if (align === AnchorAlignment.Start) {
419 1 : left = anchorPosition.left + alignmentOffset
420 1 : } else if (align === AnchorAlignment.Center) {
421 1 : left = anchorPosition.left - (elementDimensions.width - anchorPosition.width) / 2 + alignmentOffset
422 : } else {
423 : // end
424 1 : left = anchorRight - elementDimensions.width - alignmentOffset
425 : }
426 1 : } else if (side === AnchorSide.InsideLeft || side === AnchorSide.InsideRight || side === AnchorSide.InsideCenter) {
427 1 : if (align === AnchorAlignment.Start) {
428 1 : top = anchorPosition.top + alignmentOffset
429 1 : } else if (align === AnchorAlignment.Center) {
430 1 : top = anchorPosition.top - (elementDimensions.height - anchorPosition.height) / 2 + alignmentOffset
431 : } else {
432 : // end
433 1 : top = anchorBottom - elementDimensions.height - alignmentOffset
434 : }
435 1 : }
436 :
437 1 : return { top, left }
438 1 : }
439 :
440 : /// Determines if there is an overflow
441 1 : function shouldRecalculatePosition(
442 1 : side: AnchorSide,
443 1 : currentPos: Position,
444 1 : containerDimensions: BoxPosition,
445 1 : elementDimensions: Size,
446 1 : ) {
447 1 : if (side === AnchorSide.OutsideTop || side === AnchorSide.OutsideBottom) {
448 1 : return (
449 1 : currentPos.top < containerDimensions.top ||
450 1 : currentPos.top + elementDimensions.height > containerDimensions.height + containerDimensions.top
451 : )
452 : } else {
453 1 : return (
454 1 : currentPos.left < containerDimensions.left ||
455 1 : currentPos.left + elementDimensions.width > containerDimensions.width + containerDimensions.left
456 : )
457 : }
458 1 : }
459 :
460 : /// Determines if there is an overflow
461 1 : function shouldRecalculateAlignment(
462 1 : align: AnchorAlignment,
463 1 : currentPos: Position,
464 1 : containerDimensions: BoxPosition,
465 1 : elementDimensions: Size,
466 1 : ) {
467 1 : if (align === AnchorAlignment.End) {
468 1 : return currentPos.left < containerDimensions.left
469 1 : } else if (align === AnchorAlignment.Start || align === AnchorAlignment.Center) {
470 1 : return (
471 : // right edge
472 1 : currentPos.left + elementDimensions.width > containerDimensions.left + containerDimensions.width ||
473 : // left edge
474 1 : currentPos.left < containerDimensions.left
475 : )
476 : }
477 1 : }
478 :
479 : export interface AnchoredPositionHookArgs extends Partial<PositionSettings> {
480 : floatingElementRef?: React.RefObject<HTMLElement | null>
481 : anchorElementRef?: React.RefObject<Element | null>
482 : /// Observe window resizes? Callers can disable this while the floating element is hidden. @default true
483 : observeWindow?: boolean
484 : }
485 :
486 1 : export function useAnchoredPosition(args?: AnchoredPositionHookArgs, dependencies: React.DependencyList = []): {
487 : floatingElementRef: React.RefObject<Element | null>
488 : anchorElementRef: React.RefObject<Element | null>
489 : position: AnchorPosition | undefined
490 1 : } {
491 1 : const altFloatingElementRef = React.useRef<HTMLElement | null>(null);
492 1 : const altAnchorElementRef = React.useRef<Element | null>(null);
493 :
494 1 : const floatingElementRef = args?.floatingElementRef ?? altFloatingElementRef;
495 1 : const anchorElementRef = args?.anchorElementRef ?? altAnchorElementRef;
496 1 : const [position, setPosition] = React.useState<AnchorPosition | undefined>(undefined)
497 :
498 1 : const windowElem = React.useRef(document.documentElement);
499 1 : const windowSize = observeSize(windowElem, args?.observeWindow ?? true);
500 :
501 : // Update the anchored position whenever the window resizes
502 1 : React.useLayoutEffect(() => {
503 1 : if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) {
504 1 : setPosition(getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, args))
505 : } else {
506 1 : setPosition(undefined)
507 : }
508 1 : }, [windowSize, ...dependencies]);
509 :
510 1 : return {
511 1 : floatingElementRef,
512 1 : anchorElementRef,
513 1 : position,
514 1 : }
515 1 : }
|