Compare commits
4 Commits
v0.12.2-de
...
v0.12.2-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -3314,6 +3314,7 @@
|
|||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
@@ -12082,8 +12083,7 @@
|
|||||||
"version": "0.12.2",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4",
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
"@tauri-apps/cli-win32-x64-msvc": "^2.9.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4",
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
"@tauri-apps/cli-win32-x64-msvc": "^2.9.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,7 +578,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
|
|
||||||
const isDeleteMessageHovered = () => {
|
const isDeleteMessageHovered = () => {
|
||||||
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||||
|
|
||||||
@@ -1290,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
let headerEl: HTMLDivElement | undefined
|
|
||||||
let actionsEl: HTMLDivElement | undefined
|
|
||||||
let primaryEl: HTMLSpanElement | undefined
|
|
||||||
let metaMeasureEl: HTMLSpanElement | undefined
|
|
||||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1323,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||||
|
|
||||||
const updateMetaLayout = () => {
|
|
||||||
if (!hasMeta()) return
|
|
||||||
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
|
|
||||||
|
|
||||||
const headerWidth = headerEl.getBoundingClientRect().width
|
|
||||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
|
||||||
const primaryWidth = primaryEl.getBoundingClientRect().width
|
|
||||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
|
||||||
|
|
||||||
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
|
|
||||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!hasMeta() || typeof ResizeObserver === "undefined") {
|
|
||||||
setShowMetaInline(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMetaLayout()
|
|
||||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
|
||||||
if (headerEl) observer.observe(headerEl)
|
|
||||||
if (actionsEl) observer.observe(actionsEl)
|
|
||||||
if (primaryEl) observer.observe(primaryEl)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
const reasoningText = () => {
|
const reasoningText = () => {
|
||||||
const part = props.part as any
|
const part = props.part as any
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
@@ -1428,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="delete-hover-scope message-reasoning-card">
|
<div class="delete-hover-scope message-reasoning-card">
|
||||||
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
@@ -1437,7 +1403,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-reasoning-label">
|
<span class="message-reasoning-label">
|
||||||
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
|
<span class="message-reasoning-label-primary">
|
||||||
<Show when={props.showDeleteMessage}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<input
|
<input
|
||||||
class="message-select-checkbox"
|
class="message-select-checkbox"
|
||||||
@@ -1458,43 +1424,10 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Show when={hasMeta() && showMetaInline()}>
|
|
||||||
<span class="message-step-meta-inline">
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={hasMeta()}>
|
|
||||||
<span
|
|
||||||
ref={(el) => (metaMeasureEl = el)}
|
|
||||||
class="message-step-meta-inline message-step-meta-inline--measure"
|
|
||||||
>
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
|
<div class="message-reasoning-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -1543,7 +1476,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hasMeta() && !showMetaInline()}>
|
<Show when={hasMeta()}>
|
||||||
<div class="message-reasoning-meta-row">
|
<div class="message-reasoning-meta-row">
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>
|
<Show when={agentIdentifier()}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||||
import VirtualItem from "./virtual-item"
|
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
@@ -374,7 +374,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleContentRendered() {
|
function handleContentRendered() {
|
||||||
scheduleAnchorScroll()
|
if (autoScroll() && !anchorLock()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
@@ -470,9 +477,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const bottomAfter = rect.bottom
|
const bottomAfter = rect.bottom
|
||||||
const bottomBefore = bottomAfter - delta
|
const bottomBefore = bottomAfter - delta
|
||||||
const wasAboveViewport = bottomBefore < containerRect.top
|
const wasAboveViewport = bottomBefore < containerRect.top
|
||||||
if (!wasAboveViewport) {
|
if (!wasAboveViewport) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
||||||
pendingScrollCompensations.set(key, next)
|
pendingScrollCompensations.set(key, next)
|
||||||
@@ -516,25 +521,51 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pendingAutoPin = false
|
let pendingAutoPin = false
|
||||||
|
let pendingAutoPinFrame: number | null = null
|
||||||
|
|
||||||
|
function clearPendingAutoPinFrame() {
|
||||||
|
if (pendingAutoPinFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingAutoPinFrame)
|
||||||
|
pendingAutoPinFrame = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutoPinToBottom() {
|
||||||
|
if (!containerRef) return false
|
||||||
|
if (!autoScroll()) return false
|
||||||
|
if (anchorLock()) return false
|
||||||
|
|
||||||
|
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||||
|
if (containerRef.scrollTop !== maxScrollTop) {
|
||||||
|
containerRef.scrollTop = maxScrollTop
|
||||||
|
lastKnownScrollTop = maxScrollTop
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleAutoPinToBottom() {
|
function scheduleAutoPinToBottom() {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
if (pendingAutoPin) return
|
if (pendingAutoPin) return
|
||||||
pendingAutoPin = true
|
pendingAutoPin = true
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
const gen = scrollCompensationGen
|
const gen = scrollCompensationGen
|
||||||
|
|
||||||
// Flush in a microtask so adjustments land before the next paint.
|
// Flush in a microtask so adjustments land before the next paint,
|
||||||
|
// then re-apply on the next two frames to catch deferred layout.
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (gen !== scrollCompensationGen) return
|
if (gen !== scrollCompensationGen) return
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
if (!containerRef) return
|
if (!applyAutoPinToBottom()) return
|
||||||
if (!autoScroll()) return
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
if (anchorLock()) return
|
pendingAutoPinFrame = null
|
||||||
|
if (gen !== scrollCompensationGen) return
|
||||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
if (!applyAutoPinToBottom()) return
|
||||||
if (containerRef.scrollTop !== maxScrollTop) {
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
containerRef.scrollTop = maxScrollTop
|
pendingAutoPinFrame = null
|
||||||
lastKnownScrollTop = maxScrollTop
|
if (gen !== scrollCompensationGen) return
|
||||||
}
|
applyAutoPinToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +654,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
|
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
pendingActiveScroll = false
|
pendingActiveScroll = false
|
||||||
@@ -713,7 +745,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (autoScroll()) scheduleAnchorScroll(true)
|
if (autoScroll()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Drop anchor lock if the anchored key is removed.
|
// Drop anchor lock if the anchored key is removed.
|
||||||
@@ -820,6 +858,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollCompensationGen += 1
|
scrollCompensationGen += 1
|
||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
clearScrollToBottomFrames()
|
clearScrollToBottomFrames()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
@@ -883,6 +922,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const anchorId = () => getAnchorId(key())
|
const anchorId = () => getAnchorId(key())
|
||||||
const overscanPx = props.overscanPx ?? 800
|
const overscanPx = props.overscanPx ?? 800
|
||||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||||
|
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||||
return (
|
return (
|
||||||
<VirtualItem
|
<VirtualItem
|
||||||
id={anchorId()}
|
id={anchorId()}
|
||||||
@@ -890,9 +930,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollContainer={scrollElement}
|
scrollContainer={scrollElement}
|
||||||
threshold={overscanPx}
|
threshold={overscanPx}
|
||||||
placeholderClass="message-stream-placeholder"
|
placeholderClass="message-stream-placeholder"
|
||||||
virtualizationEnabled={virtualizationEnabled}
|
virtualizationEnabled={itemVirtualizationEnabled}
|
||||||
suspendMeasurements={suspendMeasurements}
|
suspendMeasurements={suspendMeasurements}
|
||||||
onHeightChange={(nextHeight, previousHeight) => {
|
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||||
const delta = nextHeight - previousHeight
|
const delta = nextHeight - previousHeight
|
||||||
|
|
||||||
// Follow mode: keep the viewport pinned to the bottom as
|
// Follow mode: keep the viewport pinned to the bottom as
|
||||||
@@ -913,12 +953,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
// while scrolling upward, compensate scrollTop so visible
|
// while scrolling upward, compensate scrollTop so visible
|
||||||
// content stays stable.
|
// content stays stable.
|
||||||
if (delta) {
|
if (delta) {
|
||||||
|
if (meta.isStaleCacheCorrection) return
|
||||||
scheduleScrollCompensation(key(), delta)
|
scheduleScrollCompensation(key(), delta)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||||
{() => props.renderItem(item(), index)}
|
|
||||||
</VirtualItem>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
|
|||||||
@@ -167,10 +167,17 @@ interface VirtualItemProps {
|
|||||||
forceVisible?: Accessor<boolean>
|
forceVisible?: Accessor<boolean>
|
||||||
suspendMeasurements?: Accessor<boolean>
|
suspendMeasurements?: Accessor<boolean>
|
||||||
onMeasured?: () => void
|
onMeasured?: () => void
|
||||||
onHeightChange?: (nextHeight: number, previousHeight: number) => void
|
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VirtualItemHeightChangeMeta {
|
||||||
|
source: "initial-visible-measure" | "resize"
|
||||||
|
previousCachedHeight: number | null
|
||||||
|
isStaleCacheCorrection: boolean
|
||||||
|
wasHidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function VirtualItem(props: VirtualItemProps) {
|
export default function VirtualItem(props: VirtualItemProps) {
|
||||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||||
@@ -183,10 +190,11 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
|
||||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||||
let pendingVisibility: boolean | null = null
|
let pendingVisibility: boolean | null = null
|
||||||
let visibilityFrame: number | null = null
|
let visibilityFrame: number | null = null
|
||||||
|
let awaitingVisibleMeasurement = true
|
||||||
|
let lastMeasurementWhileHidden = true
|
||||||
const flushVisibility = () => {
|
const flushVisibility = () => {
|
||||||
if (visibilityFrame !== null) {
|
if (visibilityFrame !== null) {
|
||||||
cancelAnimationFrame(visibilityFrame)
|
cancelAnimationFrame(visibilityFrame)
|
||||||
@@ -210,14 +218,14 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
|
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||||
const shouldHideContent = createMemo(() => {
|
const shouldHideContent = createMemo(() => {
|
||||||
if (props.forceVisible?.()) return false
|
if (forceVisible()) return false
|
||||||
if (!virtualizationEnabled()) return false
|
if (!virtualizationEnabled()) return false
|
||||||
return !isIntersecting()
|
return !isIntersecting()
|
||||||
})
|
})
|
||||||
|
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
let wrapperRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | undefined
|
let resizeObserver: ResizeObserver | undefined
|
||||||
@@ -230,6 +238,17 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleMeasurements() {
|
||||||
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
|
if (!contentRef) return
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
|
if (!contentRef) return
|
||||||
|
updateMeasuredHeight()
|
||||||
|
setupResizeObserver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupIntersectionObserver() {
|
function cleanupIntersectionObserver() {
|
||||||
if (intersectionCleanup) {
|
if (intersectionCleanup) {
|
||||||
intersectionCleanup()
|
intersectionCleanup()
|
||||||
@@ -237,13 +256,24 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistMeasurement(nextHeight: number) {
|
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
||||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const before = measuredHeight()
|
const before = measuredHeight()
|
||||||
const normalized = nextHeight
|
const normalized = nextHeight
|
||||||
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
|
||||||
|
const previous = previousCachedHeight ?? measuredHeight()
|
||||||
|
const measurementMeta: VirtualItemHeightChangeMeta = {
|
||||||
|
source: meta?.source ?? "resize",
|
||||||
|
previousCachedHeight,
|
||||||
|
isStaleCacheCorrection:
|
||||||
|
(meta?.source ?? "resize") === "initial-visible-measure" &&
|
||||||
|
previousCachedHeight !== null &&
|
||||||
|
normalized > 0 &&
|
||||||
|
Math.abs(normalized - previousCachedHeight) > 1,
|
||||||
|
wasHidden: meta?.wasHidden ?? shouldHideContent(),
|
||||||
|
}
|
||||||
// Only keep the previous measurement when the element reports 0 height.
|
// Only keep the previous measurement when the element reports 0 height.
|
||||||
// Allow shrinkage so placeholder height matches real content height;
|
// Allow shrinkage so placeholder height matches real content height;
|
||||||
// keeping the max height can cause mount/unmount jitter near the
|
// keeping the max height can cause mount/unmount jitter near the
|
||||||
@@ -254,34 +284,40 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
hasReportedMeasurement = true
|
hasReportedMeasurement = true
|
||||||
props.onMeasured?.()
|
props.onMeasured?.()
|
||||||
}
|
}
|
||||||
setHasMeasured(true)
|
|
||||||
sizeCache.set(props.cacheKey, previous)
|
sizeCache.set(props.cacheKey, previous)
|
||||||
setMeasuredHeight(previous)
|
setMeasuredHeight(previous)
|
||||||
if (previous !== before) props.onHeightChange?.(previous, before)
|
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (normalized > 0) {
|
if (normalized > 0) {
|
||||||
sizeCache.set(props.cacheKey, normalized)
|
sizeCache.set(props.cacheKey, normalized)
|
||||||
setHasMeasured(true)
|
|
||||||
if (!hasReportedMeasurement) {
|
if (!hasReportedMeasurement) {
|
||||||
hasReportedMeasurement = true
|
hasReportedMeasurement = true
|
||||||
props.onMeasured?.()
|
props.onMeasured?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMeasuredHeight(normalized)
|
setMeasuredHeight(normalized)
|
||||||
if (normalized !== before) props.onHeightChange?.(normalized, before)
|
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMeasuredHeight() {
|
function updateMeasuredHeight() {
|
||||||
if (!contentRef || measurementsSuspended()) return
|
if (!contentRef) return
|
||||||
|
if (measurementsSuspended()) return
|
||||||
// Prefer subpixel-accurate height for scroll compensation.
|
// Prefer subpixel-accurate height for scroll compensation.
|
||||||
// offsetHeight rounds to integers which can accumulate error.
|
// offsetHeight rounds to integers which can accumulate error.
|
||||||
const rect = contentRef.getBoundingClientRect()
|
const rect = contentRef.getBoundingClientRect()
|
||||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||||
if (next === measuredHeight()) return
|
const currentMeasured = measuredHeight()
|
||||||
persistMeasurement(next)
|
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
||||||
|
const wasHidden = lastMeasurementWhileHidden
|
||||||
|
if (measurementSource === "initial-visible-measure") {
|
||||||
|
awaitingVisibleMeasurement = false
|
||||||
|
lastMeasurementWhileHidden = false
|
||||||
|
}
|
||||||
|
if (next === currentMeasured) return
|
||||||
|
persistMeasurement(next, { source: measurementSource, wasHidden })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupResizeObserver() {
|
function setupResizeObserver() {
|
||||||
if (!contentRef || measurementsSuspended()) return
|
if (!contentRef || measurementsSuspended()) return
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
@@ -377,30 +413,29 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (shouldHideContent() || measurementsSuspended()) {
|
const hidden = shouldHideContent()
|
||||||
|
if (hidden) {
|
||||||
|
awaitingVisibleMeasurement = true
|
||||||
|
lastMeasurementWhileHidden = true
|
||||||
|
}
|
||||||
|
if (hidden || measurementsSuspended()) {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
} else if (contentRef) {
|
}
|
||||||
queueMicrotask(() => {
|
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||||
updateMeasuredHeight()
|
scheduleVisibleMeasurements()
|
||||||
setupResizeObserver()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const key = props.cacheKey
|
const key = props.cacheKey
|
||||||
|
|
||||||
const cached = sizeCache.get(key)
|
const cached = sizeCache.get(key)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
setMeasuredHeight(cached)
|
setMeasuredHeight(cached)
|
||||||
setHasMeasured(true)
|
|
||||||
} else {
|
} else {
|
||||||
setMeasuredHeight(fallbackPlaceholderHeight())
|
setMeasuredHeight(fallbackPlaceholderHeight())
|
||||||
setHasMeasured(false)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,7 +453,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
cleanupIntersectionObserver()
|
cleanupIntersectionObserver()
|
||||||
|
|||||||
Reference in New Issue
Block a user