|
74 | 74 | let show = false; |
75 | 75 | let triggerElement: HTMLElement | null = null; |
76 | 76 | let contentElement: HTMLElement | null = null; |
77 | | - let dropdownPosition = { top: 0, left: 0 }; |
| 77 | + let dropdownPosition = { top: 0, left: 0, maxHeight: undefined as number | undefined }; |
| 78 | + let isSmallViewport = false; |
| 79 | + let positionFrame: number | undefined; |
| 80 | + let settleTimers: number[] = []; |
78 | 81 |
|
79 | 82 | const portal = (node: HTMLElement) => { |
80 | 83 | document.body.appendChild(node); |
|
85 | 88 | }; |
86 | 89 | }; |
87 | 90 |
|
| 91 | + const visualViewportRect = () => { |
| 92 | + const viewport = window.visualViewport; |
| 93 | + return { |
| 94 | + left: viewport?.offsetLeft ?? 0, |
| 95 | + top: viewport?.offsetTop ?? 0, |
| 96 | + width: viewport?.width ?? window.innerWidth, |
| 97 | + height: viewport?.height ?? window.innerHeight |
| 98 | + }; |
| 99 | + }; |
| 100 | +
|
| 101 | + const updateViewportSize = () => { |
| 102 | + isSmallViewport = (window.visualViewport?.width ?? window.innerWidth) < 640; |
| 103 | + }; |
| 104 | +
|
88 | 105 | const updatePosition = () => { |
89 | 106 | if (!show || !triggerElement) return; |
90 | 107 | const rect = triggerElement.getBoundingClientRect(); |
91 | 108 | const contentRect = contentElement?.getBoundingClientRect(); |
92 | 109 | const contentWidth = contentRect?.width ?? 0; |
93 | 110 | const contentHeight = contentRect?.height ?? 0; |
94 | | - const spaceBelow = window.innerHeight - rect.bottom - 8; |
95 | | - const spaceAbove = rect.top - 8; |
| 111 | + const viewport = visualViewportRect(); |
| 112 | + const viewportRight = viewport.left + viewport.width; |
| 113 | + const viewportBottom = viewport.top + viewport.height; |
| 114 | + const pad = 8; |
| 115 | + const gap = 2; |
| 116 | + const spaceBelow = viewportBottom - rect.bottom - gap - pad; |
| 117 | + const spaceAbove = rect.top - viewport.top - gap - pad; |
96 | 118 | const preferredLeft = align === 'end' && contentWidth ? rect.right - contentWidth : rect.left; |
97 | | - const maxLeft = contentWidth ? window.innerWidth - contentWidth - 8 : preferredLeft; |
| 119 | + const maxLeft = contentWidth ? viewportRight - contentWidth - pad : preferredLeft; |
98 | 120 | const resolvedPlacement = |
99 | 121 | placement === 'auto' |
100 | 122 | ? contentHeight && spaceBelow < contentHeight && spaceAbove > spaceBelow |
101 | 123 | ? 'top' |
102 | 124 | : 'bottom' |
103 | 125 | : placement; |
| 126 | + const availableHeight = resolvedPlacement === 'top' ? spaceAbove : spaceBelow; |
| 127 | + const constrainedHeight = |
| 128 | + contentHeight && availableHeight >= 0 |
| 129 | + ? Math.min(contentHeight, availableHeight) |
| 130 | + : contentHeight; |
| 131 | + const top = |
| 132 | + resolvedPlacement === 'top' && contentHeight |
| 133 | + ? rect.top - constrainedHeight - gap |
| 134 | + : rect.bottom + gap; |
| 135 | +
|
104 | 136 | dropdownPosition = { |
105 | | - top: |
106 | | - resolvedPlacement === 'top' && contentHeight |
107 | | - ? rect.top - contentHeight - 2 |
108 | | - : rect.bottom + 2, |
109 | | - left: Math.max(8, Math.min(preferredLeft, maxLeft)) |
| 137 | + top: Math.max(viewport.top + pad, Math.min(top, viewportBottom - pad - constrainedHeight)), |
| 138 | + left: Math.max(viewport.left + pad, Math.min(preferredLeft, maxLeft)), |
| 139 | + maxHeight: |
| 140 | + contentHeight && availableHeight >= 0 && contentHeight > availableHeight |
| 141 | + ? Math.max(0, availableHeight) |
| 142 | + : undefined |
110 | 143 | }; |
111 | 144 | }; |
112 | 145 |
|
| 146 | + const schedulePositionUpdate = () => { |
| 147 | + if (positionFrame != null) cancelAnimationFrame(positionFrame); |
| 148 | + positionFrame = requestAnimationFrame(() => { |
| 149 | + positionFrame = undefined; |
| 150 | + updatePosition(); |
| 151 | + }); |
| 152 | + }; |
| 153 | +
|
| 154 | + const scheduleSettledPositionUpdates = () => { |
| 155 | + updateViewportSize(); |
| 156 | + for (const timer of settleTimers) window.clearTimeout(timer); |
| 157 | + settleTimers = []; |
| 158 | + schedulePositionUpdate(); |
| 159 | + for (const delay of [50, 150, 300]) { |
| 160 | + settleTimers.push(window.setTimeout(schedulePositionUpdate, delay)); |
| 161 | + } |
| 162 | + }; |
| 163 | +
|
113 | 164 | const toggleOpen = async () => { |
114 | 165 | show = !show; |
115 | 166 | if (show) { |
|
495 | 546 | ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); |
496 | 547 | }; |
497 | 548 |
|
498 | | - onMount(async () => { |
| 549 | + onMount(() => { |
499 | 550 | if (items) { |
500 | 551 | tags = items |
501 | 552 | .filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false)) |
|
504 | 555 | // Remove duplicates and sort |
505 | 556 | tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b)); |
506 | 557 | } |
| 558 | +
|
| 559 | + updateViewportSize(); |
| 560 | + window.addEventListener('scroll', schedulePositionUpdate, true); |
| 561 | + window.visualViewport?.addEventListener('resize', scheduleSettledPositionUpdates); |
| 562 | + window.visualViewport?.addEventListener('scroll', schedulePositionUpdate); |
| 563 | +
|
| 564 | + return () => { |
| 565 | + if (positionFrame != null) cancelAnimationFrame(positionFrame); |
| 566 | + for (const timer of settleTimers) window.clearTimeout(timer); |
| 567 | + window.removeEventListener('scroll', schedulePositionUpdate, true); |
| 568 | + window.visualViewport?.removeEventListener('resize', scheduleSettledPositionUpdates); |
| 569 | + window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate); |
| 570 | + }; |
507 | 571 | }); |
508 | 572 |
|
509 | 573 | $: if (show && !selectionOnly) { |
|
585 | 649 |
|
586 | 650 | let listScrollTop = 0; |
587 | 651 | let listContainer; |
| 652 | + $: listViewportHeight = isSmallViewport ? 120 : 288; |
588 | 653 |
|
589 | 654 | $: visibleStart = Math.max(0, Math.floor(listScrollTop / ITEM_HEIGHT) - OVERSCAN); |
590 | 655 | $: visibleEnd = Math.min( |
591 | 656 | filteredItems.length, |
592 | | - Math.ceil((listScrollTop + 288) / ITEM_HEIGHT) + OVERSCAN |
| 657 | + Math.ceil((listScrollTop + listViewportHeight) / ITEM_HEIGHT) + OVERSCAN |
593 | 658 | ); |
594 | 659 | </script> |
595 | 660 |
|
|
607 | 672 | <svelte:window |
608 | 673 | on:pointerdown={handlePointerDown} |
609 | 674 | on:keydown={handleKeydown} |
610 | | - on:resize={updatePosition} |
| 675 | + on:resize={scheduleSettledPositionUpdates} |
611 | 676 | /> |
612 | 677 |
|
613 | 678 | <div class="relative w-full"> |
|
649 | 714 | <div |
650 | 715 | use:portal |
651 | 716 | bind:this={contentElement} |
652 | | - style="position: fixed; z-index: 9999; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;" |
| 717 | + style="position: fixed; z-index: 9999; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; {dropdownPosition.maxHeight |
| 718 | + ? `max-height: ${dropdownPosition.maxHeight}px;` |
| 719 | + : ''}" |
653 | 720 | > |
654 | 721 | <div |
655 | 722 | class="z-40 {className} max-w-[calc(100vw-1rem)] justify-start rounded-xl border border-gray-100 bg-white p-0.5 shadow-lg outline-hidden dark:border-gray-800 dark:bg-gray-850 dark:text-white" |
|
698 | 765 | <button |
699 | 766 | type="button" |
700 | 767 | class="flex size-[1.375rem] shrink-0 items-center justify-center rounded-lg transition-colors duration-100 {compareEnabled |
701 | | - ? 'bg-gray-100/60 text-gray-700 hover:bg-gray-100/60 dark:bg-gray-800/40 dark:text-gray-300 dark:hover:bg-gray-800/40' |
| 768 | + ? 'bg-gray-50 text-gray-700 hover:bg-gray-50 dark:bg-gray-800/60 dark:text-gray-200 dark:hover:bg-gray-800/60' |
702 | 769 | : 'text-gray-500 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200'}" |
703 | 770 | aria-label={$i18n.t('Compare')} |
704 | 771 | aria-pressed={compareEnabled} |
|
758 | 825 | {:else} |
759 | 826 | <!-- svelte-ignore a11y-no-static-element-interactions --> |
760 | 827 | <div |
761 | | - class="max-h-72 overflow-y-auto" |
| 828 | + class="overflow-y-auto" |
| 829 | + style="max-height: {listViewportHeight}px;" |
762 | 830 | role="listbox" |
763 | 831 | aria-label={$i18n.t('Available models')} |
764 | 832 | bind:this={listContainer} |
|
0 commit comments