ui: remove render effects (#26083)
* ui: remove viewport fade in and smooth autoscroll bottom snap fadeInView mounted every message and markdown block at opacity 0 and relied on an IntersectionObserver to reveal it. When the observer never fires (blocks mounted offscreen during long agentic loops) the content stays invisible forever while still present in the DOM. Remove the action, its orphaned isElementInViewport util and all call sites: blocks now render visible immediately. AutoScrollController.scrollToBottom defaulted to behavior smooth and is invoked every 100 ms while streaming. Each tick restarts an easing animation toward a moving scrollHeight, producing a random elastic bump of a few pixels when the user reaches the bottom and autoscroll reengages. Default to instant scrolling; the user facing scroll down button keeps its smooth behavior. * ui: skip rendering of offscreen chat messages via content-visibility Apply content-visibility auto with contain-intrinsic-size to chat messages so the browser skips layout and paint for messages outside the viewport. The DOM stays complete: component state, find-in-page, text selection, and the mutation based autoscroll are unaffected, and browsers without support simply ignore the properties. * ui: remove conversation switch fade Switching conversations faded the message list out and in over 500 ms plus a 300 ms route delay, deferring the message refresh behind two requestAnimationFrame calls. Remove the fade, its navigation hooks and dead state, and refresh messages directly so switching is only bound by actual render time. * ui: describe present behavior in comments and drop unused parameter * ui: anchor the context gauge popup to the form with plain CSS The stats card was portaled to body and repositioned in script on every ancestor scroll event, trailing the page by one frame while streaming. Render it as an absolutely positioned sibling of the input box inside the already relative form, so nothing runs during scroll. The card sits just above the dial, centered on it and overlapping the textarea, from a single measurement of the dial center and top taken when it opens; the dial and the card share the same positioning frame, so the values stay exact while the card is open. Mouse pointers open on hover with a grace delay to reach the card, touch pointers toggle on tap, any press outside the card and the dial closes it, and Enter and Space toggle from the keyboard. The card lives outside the input box because its overflow-hidden and backdrop-filter would clip any positioned descendant. * ui: extract context gauge popup constants and relocate its state store Move the placement values and the close grace delay to lib/constants/context-gauge-popup.ts, matching the auto-scroll constants layout, and move the popup state module from the component folder to lib/stores where runes modules live in this codebase. * ui: declare the context gauge popup card ref as $state
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
import { isElementInViewport } from '$lib/utils/viewport';
|
||||
|
||||
/**
|
||||
* Svelte action that fades in an element when it enters the viewport.
|
||||
* Uses IntersectionObserver for efficient viewport detection.
|
||||
*
|
||||
* If skipIfVisible is set and the element is already visible in the viewport
|
||||
* when the action attaches (e.g. a markdown block promoted from unstable
|
||||
* during streaming), the fade is skipped entirely to avoid a flash.
|
||||
*/
|
||||
export function fadeInView(
|
||||
node: HTMLElement,
|
||||
options: { duration?: number; y?: number; delay?: number; skipIfVisible?: boolean } = {}
|
||||
) {
|
||||
const { duration = 300, y = 0, delay = 0, skipIfVisible = false } = options;
|
||||
|
||||
if (skipIfVisible && isElementInViewport(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.style.opacity = '0';
|
||||
node.style.transform = `translateY(${y}px)`;
|
||||
node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
|
||||
|
||||
$effect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
node.style.opacity = '1';
|
||||
node.style.transform = 'translateY(0)';
|
||||
});
|
||||
}, delay);
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.05 }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
SpecialFileType
|
||||
} from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
|
||||
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
@@ -556,6 +557,8 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextGaugePopup />
|
||||
</form>
|
||||
|
||||
<DialogMcpResourcesBrowser
|
||||
|
||||
+21
-75
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
|
||||
import { formatParameters } from '$lib/utils/formatters';
|
||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||
import ContextGaugeDial from './ContextGaugeDial.svelte';
|
||||
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
|
||||
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
|
||||
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
|
||||
import {
|
||||
gaugeTriggerClick,
|
||||
gaugeTriggerEnter,
|
||||
gaugeTriggerKeydown,
|
||||
gaugeTriggerLeave,
|
||||
gaugeTriggerPointerDown
|
||||
} from '$lib/stores/context-gauge-popup.svelte';
|
||||
|
||||
const gauge = useContextGauge();
|
||||
|
||||
@@ -34,75 +36,19 @@
|
||||
$effect(() => {
|
||||
gauge.startMonitoring();
|
||||
});
|
||||
|
||||
const showProgressBar = $derived(
|
||||
gauge.contextTotal !== null &&
|
||||
gauge.contextTotal > 0 &&
|
||||
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
|
||||
);
|
||||
</script>
|
||||
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger class="flex h-5 w-5 cursor-default items-center justify-center">
|
||||
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
|
||||
</HoverCard.Trigger>
|
||||
|
||||
<HoverCard.Content
|
||||
side="bottom"
|
||||
class="z-50 w-64 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Context</span>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span class="font-mono text-muted-foreground">
|
||||
{formatParameters(gauge.contextUsed)}
|
||||
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
|
||||
<ContextGaugeLoadModel
|
||||
modelId={gauge.activeModelId}
|
||||
isLoading={gauge.isActiveModelLoading}
|
||||
onLoad={gauge.loadModel}
|
||||
/>
|
||||
{:else if showProgressBar}
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
|
||||
gauge.colorLevel
|
||||
)}"
|
||||
style="width: {gauge.contextPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
|
||||
</span>
|
||||
<span>
|
||||
{formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground">No context info available</div>
|
||||
{/if}
|
||||
|
||||
{#if gauge.hasAnyUsage}
|
||||
<ContextGaugeDetails
|
||||
currentRead={gauge.currentRead}
|
||||
currentFresh={gauge.currentFresh}
|
||||
currentCache={gauge.currentCache}
|
||||
currentOutput={gauge.currentOutput}
|
||||
kvTotal={gauge.kvTotal}
|
||||
cumulativeRead={gauge.cumulativeRead}
|
||||
cumulativeOutput={gauge.cumulativeOutput}
|
||||
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
|
||||
averageTokensPerSecond={gauge.averageTokensPerSecond}
|
||||
transientDetails={gauge.transientDetails}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Context usage"
|
||||
data-context-gauge-trigger
|
||||
class="flex h-5 w-5 cursor-default items-center justify-center"
|
||||
onclick={gaugeTriggerClick}
|
||||
onkeydown={gaugeTriggerKeydown}
|
||||
onpointerdown={gaugeTriggerPointerDown}
|
||||
onpointerenter={gaugeTriggerEnter}
|
||||
onpointerleave={gaugeTriggerLeave}
|
||||
>
|
||||
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
|
||||
</div>
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { formatParameters } from '$lib/utils/formatters';
|
||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
|
||||
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
|
||||
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
|
||||
import {
|
||||
gaugePopup,
|
||||
gaugeCardEnter,
|
||||
gaugeCardLeave,
|
||||
gaugePopupClose
|
||||
} from '$lib/stores/context-gauge-popup.svelte';
|
||||
|
||||
const gauge = useContextGauge();
|
||||
|
||||
let cardEl = $state<HTMLElement | null>(null);
|
||||
|
||||
// Any press outside the card and outside the dial closes the card.
|
||||
// Presses on the dial are excluded because the dial handles its own
|
||||
// toggle; the listener only exists while the card is open.
|
||||
$effect(() => {
|
||||
if (!gaugePopup.open) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (cardEl?.contains(target)) return;
|
||||
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
|
||||
gaugePopupClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
});
|
||||
|
||||
const showProgressBar = $derived(
|
||||
gauge.contextTotal !== null &&
|
||||
gauge.contextTotal > 0 &&
|
||||
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if gaugePopup.open}
|
||||
<div
|
||||
role="status"
|
||||
bind:this={cardEl}
|
||||
class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
|
||||
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
|
||||
onpointerenter={gaugeCardEnter}
|
||||
onpointerleave={gaugeCardLeave}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Context</span>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span class="font-mono text-muted-foreground">
|
||||
{formatParameters(gauge.contextUsed)}
|
||||
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
|
||||
<ContextGaugeLoadModel
|
||||
modelId={gauge.activeModelId}
|
||||
isLoading={gauge.isActiveModelLoading}
|
||||
onLoad={gauge.loadModel}
|
||||
/>
|
||||
{:else if showProgressBar}
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
|
||||
gauge.colorLevel
|
||||
)}"
|
||||
style="width: {gauge.contextPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
|
||||
</span>
|
||||
<span>
|
||||
{formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground">No context info available</div>
|
||||
{/if}
|
||||
|
||||
{#if gauge.hasAnyUsage}
|
||||
<ContextGaugeDetails
|
||||
currentRead={gauge.currentRead}
|
||||
currentFresh={gauge.currentFresh}
|
||||
currentCache={gauge.currentCache}
|
||||
currentOutput={gauge.currentOutput}
|
||||
kvTotal={gauge.kvTotal}
|
||||
cumulativeRead={gauge.cumulativeRead}
|
||||
cumulativeOutput={gauge.cumulativeOutput}
|
||||
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
|
||||
averageTokensPerSecond={gauge.averageTokensPerSecond}
|
||||
transientDetails={gauge.transientDetails}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,7 +7,6 @@
|
||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
|
||||
import { REASONING_TAGS } from '$lib/constants/agentic';
|
||||
import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
@@ -328,7 +327,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div use:fadeInView class="chat-message">
|
||||
<div class="chat-message">
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem
|
||||
bind:textareaElement
|
||||
@@ -398,3 +397,15 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The browser skips layout and paint for messages outside the
|
||||
* viewport. contain-intrinsic-size reuses the last rendered size
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
-2
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
|
||||
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
|
||||
|
||||
@@ -30,7 +29,6 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:fadeInView
|
||||
aria-label="Pending user message"
|
||||
class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky bottom-32"
|
||||
role="group"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
|
||||
import { setChatActionsContext } from '$lib/contexts';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
@@ -35,9 +33,6 @@
|
||||
let { messages = [], onUserAction, onMessagesReady }: Props = $props();
|
||||
|
||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||
let isVisible = $state(false);
|
||||
let previousConversationId = $state<string | null>(null);
|
||||
let previousRouteId = $state<string | null>(null);
|
||||
|
||||
const currentConfig = config();
|
||||
|
||||
@@ -123,26 +118,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Track conversation changes to trigger transition even on same route
|
||||
// Refresh messages whenever the active conversation changes
|
||||
$effect(() => {
|
||||
const conversation = activeConversation();
|
||||
const currentId = conversation?.id ?? null;
|
||||
|
||||
if (currentId !== previousConversationId && previousConversationId !== null) {
|
||||
// Conversation changed - trigger fade out/in
|
||||
isVisible = false;
|
||||
requestAnimationFrame(() => {
|
||||
refreshAllMessages();
|
||||
previousConversationId = currentId;
|
||||
requestAnimationFrame(() => {
|
||||
isVisible = true;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
previousConversationId = currentId;
|
||||
if (conversation) {
|
||||
refreshAllMessages();
|
||||
}
|
||||
if (activeConversation()) {
|
||||
refreshAllMessages();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,23 +131,6 @@
|
||||
onMessagesReady?.(displayMessages.length);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
requestAnimationFrame(() => {
|
||||
isVisible = true;
|
||||
});
|
||||
});
|
||||
|
||||
beforeNavigate((navigation) => {
|
||||
isVisible = false;
|
||||
previousRouteId = navigation.from?.route.id ?? null;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
requestAnimationFrame(() => {
|
||||
isVisible = true;
|
||||
});
|
||||
});
|
||||
|
||||
let siblingInfoByMessageId = $derived(buildSiblingInfoMap(allConversationMessages));
|
||||
|
||||
let displayMessages = $derived.by(() => {
|
||||
@@ -272,11 +234,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="transition-opacity duration-500 ease-out
|
||||
{isVisible ? 'opacity-100' : 'opacity-0'}
|
||||
{previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
|
||||
>
|
||||
<div>
|
||||
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
|
||||
<ChatMessage
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
|
||||
|
||||
@@ -10,10 +9,7 @@
|
||||
</script>
|
||||
|
||||
{#if hasError}
|
||||
<div
|
||||
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
|
||||
use:fadeInView={{ y: 10, duration: 250 }}
|
||||
>
|
||||
<div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
|
||||
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
|
||||
{#if isLoadingModel}
|
||||
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
|
||||
interface Props {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
@@ -828,7 +827,7 @@
|
||||
: ''}"
|
||||
>
|
||||
{#each renderedBlocks as block (block.id)}
|
||||
<div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
|
||||
<div class="markdown-block" data-block-id={block.id}>
|
||||
{@html block.html}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Half of the card width, matching the w-64 class on the card.
|
||||
export const CONTEXT_GAUGE_CARD_HALF_WIDTH_PX = 128;
|
||||
// Minimum distance kept between the card and the form edges.
|
||||
export const CONTEXT_GAUGE_EDGE_MARGIN_PX = 8;
|
||||
// Gap between the top of the dial and the bottom edge of the card.
|
||||
export const CONTEXT_GAUGE_DIAL_GAP_PX = 8;
|
||||
// Grace delay before closing, letting the pointer travel from dial to card.
|
||||
export const CONTEXT_GAUGE_CLOSE_GRACE_MS = 150;
|
||||
@@ -12,6 +12,7 @@ export * from './recommended-mcp-servers';
|
||||
export * from './storage';
|
||||
export * from './attachment-menu';
|
||||
export * from './auto-scroll';
|
||||
export * from './context-gauge-popup';
|
||||
export * from './binary-detection';
|
||||
export * from './built-in-tools';
|
||||
export * from './cache';
|
||||
|
||||
@@ -84,11 +84,11 @@ export class AutoScrollController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the container to the bottom.
|
||||
* Scrolls the container to the bottom instantly.
|
||||
*/
|
||||
scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
|
||||
scrollToBottom(): void {
|
||||
if (this._disabled || !this._container) return;
|
||||
this._container.scrollTo({ top: this._container.scrollHeight, behavior });
|
||||
this._container.scrollTop = this._container.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Shared state for the context gauge popup. The dial and the card live in
|
||||
// different DOM subtrees, so open state and placement are coordinated here.
|
||||
// centerX and bottom place the card just above the dial: both are measured
|
||||
// once at open time, relative to the closest form ancestor; the dial and
|
||||
// the card share that positioning frame, so the values stay exact for the
|
||||
// whole time the card is open.
|
||||
// Mouse pointers open on hover with a short grace delay to travel from
|
||||
// dial to card; touch pointers toggle on tap.
|
||||
import {
|
||||
CONTEXT_GAUGE_CARD_HALF_WIDTH_PX,
|
||||
CONTEXT_GAUGE_CLOSE_GRACE_MS,
|
||||
CONTEXT_GAUGE_DIAL_GAP_PX,
|
||||
CONTEXT_GAUGE_EDGE_MARGIN_PX
|
||||
} from '$lib/constants';
|
||||
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastPointerType = '';
|
||||
|
||||
export const gaugePopup = $state({ open: false, centerX: 0, bottom: 0 });
|
||||
|
||||
function openFrom(trigger: HTMLElement): void {
|
||||
clearTimeout(closeTimer);
|
||||
const frame = trigger.closest('form');
|
||||
if (frame) {
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const centerX = triggerRect.left + triggerRect.width / 2 - frameRect.left;
|
||||
const min = CONTEXT_GAUGE_CARD_HALF_WIDTH_PX + CONTEXT_GAUGE_EDGE_MARGIN_PX;
|
||||
const max = frameRect.width - CONTEXT_GAUGE_CARD_HALF_WIDTH_PX - CONTEXT_GAUGE_EDGE_MARGIN_PX;
|
||||
gaugePopup.centerX = Math.min(Math.max(centerX, min), Math.max(min, max));
|
||||
gaugePopup.bottom = frameRect.bottom - triggerRect.top + CONTEXT_GAUGE_DIAL_GAP_PX;
|
||||
}
|
||||
gaugePopup.open = true;
|
||||
}
|
||||
|
||||
function toggleFrom(trigger: HTMLElement): void {
|
||||
if (gaugePopup.open) {
|
||||
clearTimeout(closeTimer);
|
||||
gaugePopup.open = false;
|
||||
} else {
|
||||
openFrom(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
export function gaugePopupClose(): void {
|
||||
clearTimeout(closeTimer);
|
||||
gaugePopup.open = false;
|
||||
}
|
||||
|
||||
export function gaugeTriggerPointerDown(event: PointerEvent): void {
|
||||
lastPointerType = event.pointerType;
|
||||
}
|
||||
|
||||
export function gaugeTriggerClick(event: MouseEvent): void {
|
||||
if (lastPointerType !== 'touch') return;
|
||||
toggleFrom(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
export function gaugeTriggerKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
toggleFrom(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
export function gaugeTriggerEnter(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
openFrom(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
export function gaugeTriggerLeave(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
scheduleClose();
|
||||
}
|
||||
|
||||
export function gaugeCardEnter(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
clearTimeout(closeTimer);
|
||||
}
|
||||
|
||||
export function gaugeCardLeave(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
scheduleClose();
|
||||
}
|
||||
|
||||
function scheduleClose(): void {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
gaugePopup.open = false;
|
||||
}, CONTEXT_GAUGE_CLOSE_GRACE_MS);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Check if an element is within the current viewport.
|
||||
*/
|
||||
export function isElementInViewport(node: HTMLElement): boolean {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return (
|
||||
rect.top < window.innerHeight &&
|
||||
rect.bottom > 0 &&
|
||||
rect.left < window.innerWidth &&
|
||||
rect.right > 0
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user