mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
*PR Created by the Glary-Bot Agent* --- ## Summary Fixes the bug where the last badge in `ContentSection`'s sticky sidebar nav stays unhighlighted when the user scrolls to the very bottom of the page on tall viewports (reported on a 14" MacBook M4 Pro at 3024×1964 / 2016×1310 logical, both Chrome and Safari). ## Root cause The scroll-spy uses an IntersectionObserver with `rootMargin: '-20% 0px -60% 0px'`, which makes only a 20%–40% horizontal band from the viewport top "active". When multiple intersecting entries are reported, the callback picks the one whose `boundingClientRect.top` is smallest (highest up on screen). On tall viewports, when the page is scrolled to the absolute bottom, the last *and* the second-to-last sections frequently both sit inside that 20%–40% band at the same time. The "smallest top" tiebreak then selects the second-to-last section, leaving the last badge inactive even though the user has reached the end of the page. ## Fix `apps/website/src/components/common/ContentSection.vue`: 1. Add `isAtBottom()` — true when the viewport bottom has reached the document bottom (within 4px to absorb sub-pixel rounding). 2. The IntersectionObserver callback bails out when `isAtBottom()` so it cannot overwrite the choice below. 3. A passive `scroll` listener (and a one-shot `onMounted` call) sets `activeSection` to the last section whenever the page is at the bottom — including when the component mounts already at the bottom (e.g. hash navigation to a trailing anchor, restored scroll position, or a page shorter than the viewport). 4. Both the scroll handler and the IO callback honor the existing `isScrolling` flag, so click-driven smooth scroll-to-section behavior is unchanged. ## Verification Reproduced the bug at viewport 2016×1310 (14" M4 Pro "More Space" mode) on `/privacy-policy`: - Before fix: at absolute bottom, IntersectionObserver picks `australian-privacy` (second-to-last) — bug confirmed via DOM inspection that showed multiple sections intersecting the active band, with the second-to-last winning the "smallest top" tiebreak. - After fix: - Scrolled to bottom → last badge `CONTACT` is active. - Scrolled to top → first badge `INTRO` is active. - Scrolled mid-page → correct mid-section is active. - Click on a badge → smooth scrolls and that badge becomes active. - Initial render at bottom (loaded `/privacy-policy#contact`, browser scrolls to the bottom on mount) → `CONTACT` active immediately. `pnpm typecheck` and `pnpm typecheck:website` pass; `pnpm lint` reports 0 errors; existing website unit tests pass. Note: The website app currently has no Vue component test setup (`vitest.config.ts` is configured for `node` env, no DOM). Adding component tests for this scroll-spy interaction would require setting up `happy-dom` and `@testing-library/vue` for the website app, which is out of scope for this bug fix. Fixes FE-604 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12057-FE-604-fix-website-activate-last-section-badge-when-scrolled-to-bottom-3596d73d365081faa243f4dd8e6ee54a) by [Unito](https://www.unito.io) --------- Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
272 lines
8.4 KiB
Vue
272 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import { cn } from '@comfyorg/tailwind-utils'
|
|
import {
|
|
useEventListener,
|
|
useIntersectionObserver,
|
|
useTemplateRefsList
|
|
} from '@vueuse/core'
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
import type { Locale, TranslationKey } from '../../i18n/translations'
|
|
|
|
import { hasKey, t } from '../../i18n/translations'
|
|
import { scrollTo } from '../../scripts/smoothScroll'
|
|
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
|
import BrandButton from './BrandButton.vue'
|
|
import CategoryNav from './CategoryNav.vue'
|
|
import SectionLabel from './SectionLabel.vue'
|
|
import { deriveSections } from '../../config/contentSections'
|
|
|
|
const {
|
|
prefix,
|
|
locale = 'en',
|
|
readMoreHref
|
|
} = defineProps<{
|
|
prefix: string
|
|
locale?: Locale
|
|
readMoreHref?: string
|
|
}>()
|
|
|
|
const sections = deriveSections(prefix)
|
|
|
|
function key(sectionId: string, suffix: string): TranslationKey {
|
|
return `${prefix}.${sectionId}.${suffix}` as TranslationKey
|
|
}
|
|
|
|
const categories = computed(() =>
|
|
sections.map((s) => ({
|
|
label: t(key(s.id, 'label'), locale),
|
|
value: s.id
|
|
}))
|
|
)
|
|
|
|
const activeSection = ref(sections[0]?.id ?? '')
|
|
|
|
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
|
let isScrolling = false
|
|
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
const HEADER_OFFSET = -144
|
|
const BOTTOM_THRESHOLD_PX = 4
|
|
const SCROLL_SAFETY_MS = 1500
|
|
|
|
function clearScrollLock() {
|
|
isScrolling = false
|
|
if (scrollSafetyTimer !== undefined) {
|
|
clearTimeout(scrollSafetyTimer)
|
|
scrollSafetyTimer = undefined
|
|
}
|
|
}
|
|
|
|
useIntersectionObserver(
|
|
sectionRefs,
|
|
(entries) => {
|
|
if (isScrolling) return
|
|
if (isAtBottom()) return
|
|
let best: IntersectionObserverEntry | null = null
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue
|
|
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
|
|
best = entry
|
|
}
|
|
if (best) activeSection.value = best.target.id
|
|
},
|
|
{ rootMargin: '-20% 0px -60% 0px' }
|
|
)
|
|
|
|
function isAtBottom(): boolean {
|
|
const scrollBottom = window.scrollY + window.innerHeight
|
|
return (
|
|
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
|
)
|
|
}
|
|
|
|
function activateLastIfAtBottom() {
|
|
if (isScrolling) return
|
|
if (!isAtBottom()) return
|
|
const lastId = sections[sections.length - 1]?.id
|
|
if (lastId) activeSection.value = lastId
|
|
}
|
|
|
|
onMounted(activateLastIfAtBottom)
|
|
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
|
|
|
function scrollToSection(id: string) {
|
|
activeSection.value = id
|
|
clearScrollLock()
|
|
isScrolling = true
|
|
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
|
const el = document.getElementById(id)
|
|
if (el) {
|
|
scrollTo(el, {
|
|
offset: HEADER_OFFSET,
|
|
duration: 0.8,
|
|
immediate: prefersReducedMotion(),
|
|
onComplete: clearScrollLock
|
|
})
|
|
return
|
|
}
|
|
clearScrollLock()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
|
<div class="lg:flex lg:gap-16">
|
|
<!-- Desktop sticky nav -->
|
|
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
|
|
<div class="sticky top-32">
|
|
<CategoryNav
|
|
:categories="categories"
|
|
:model-value="activeSection"
|
|
@update:model-value="scrollToSection"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1">
|
|
<div
|
|
v-for="section in sections"
|
|
:id="section.id"
|
|
:ref="sectionRefs.set"
|
|
:key="section.id"
|
|
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
|
|
>
|
|
<h2
|
|
v-if="section.hasTitle"
|
|
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
|
|
>
|
|
{{ t(key(section.id, 'title'), locale) }}
|
|
</h2>
|
|
|
|
<template v-for="(block, i) in section.blocks" :key="i">
|
|
<!-- Paragraph -->
|
|
<p
|
|
v-if="block.type === 'paragraph'"
|
|
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
|
|
v-html="t(key(section.id, `block.${i}`), locale)"
|
|
/>
|
|
|
|
<!-- Heading (h3) -->
|
|
<h3
|
|
v-else-if="block.type === 'heading'"
|
|
class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic"
|
|
>
|
|
{{ t(key(section.id, `block.${i}.heading`), locale) }}
|
|
</h3>
|
|
|
|
<!-- Bullet list -->
|
|
<ul
|
|
v-else-if="block.type === 'list'"
|
|
class="mt-4 space-y-1 pl-5 text-sm"
|
|
>
|
|
<li
|
|
v-for="(item, j) in t(
|
|
key(section.id, `block.${i}`),
|
|
locale
|
|
).split('\n')"
|
|
:key="j"
|
|
class="text-primary-comfy-canvas flex items-start gap-2"
|
|
>
|
|
<span
|
|
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
|
|
/>
|
|
{{ item }}
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Ordered list -->
|
|
<ol
|
|
v-else-if="block.type === 'ordered-list'"
|
|
class="mt-4 space-y-1 pl-1 text-sm"
|
|
>
|
|
<li
|
|
v-for="(item, j) in t(
|
|
key(section.id, `block.${i}.ol`),
|
|
locale
|
|
).split('\n')"
|
|
:key="j"
|
|
class="text-primary-comfy-canvas flex items-start gap-3"
|
|
>
|
|
<span
|
|
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
|
|
>
|
|
{{ String(j + 1).padStart(2, '0') }}
|
|
</span>
|
|
{{ item }}
|
|
</li>
|
|
</ol>
|
|
|
|
<!-- Image with caption -->
|
|
<figure v-else-if="block.type === 'image'" class="my-8">
|
|
<img
|
|
:src="t(key(section.id, `block.${i}.src`), locale)"
|
|
:alt="t(key(section.id, `block.${i}.alt`), locale)"
|
|
class="w-full rounded-2xl object-cover"
|
|
/>
|
|
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
|
|
{{ t(key(section.id, `block.${i}.caption`), locale) }}
|
|
</figcaption>
|
|
</figure>
|
|
|
|
<!-- Blockquote -->
|
|
<blockquote
|
|
v-else-if="block.type === 'blockquote'"
|
|
:class="
|
|
cn(
|
|
'border-primary-comfy-yellow my-8 rounded-2xl border-l-4 p-8',
|
|
'bg-(--site-bg-soft)'
|
|
)
|
|
"
|
|
>
|
|
<p
|
|
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
|
|
>
|
|
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
|
|
</p>
|
|
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">
|
|
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
|
</p>
|
|
</blockquote>
|
|
|
|
<!-- Author card -->
|
|
<div
|
|
v-else-if="block.type === 'author'"
|
|
:class="cn('mt-8 rounded-2xl p-6', 'bg-(--site-bg-soft)')"
|
|
>
|
|
<SectionLabel>
|
|
{{ t(key(section.id, `block.${i}.label`), locale) }}
|
|
</SectionLabel>
|
|
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
|
|
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
|
</p>
|
|
<p class="text-primary-comfy-canvas text-xs">
|
|
{{ t(key(section.id, `block.${i}.role`), locale) }}
|
|
</p>
|
|
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
|
|
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
|
|
{{ t(key(section.id, `block.${i}.name2`), locale) }}
|
|
</p>
|
|
<p class="text-primary-comfy-canvas text-xs">
|
|
{{ t(key(section.id, `block.${i}.role2`), locale) }}
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Read more CTA -->
|
|
<div v-if="readMoreHref" class="mt-8 flex justify-center">
|
|
<BrandButton :href="readMoreHref" variant="solid" size="lg">
|
|
<span class="ppformula-text-center flex items-center gap-2">
|
|
{{ t('customers.story.readMore' as TranslationKey, locale) }}
|
|
<span class="text-base">↗</span>
|
|
</span>
|
|
</BrandButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|