feat(FontList): unified skeleton — rows stay skeletal until font file loaded
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
VIRTUAL_INDEX_NOT_LOADED,
|
VIRTUAL_INDEX_NOT_LOADED,
|
||||||
|
appliedFontsManager,
|
||||||
fontStore,
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { getSkeletonWidth } from '$shared/lib/utils';
|
import { getSkeletonWidth } from '$shared/lib/utils';
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import DotIcon from '@lucide/svelte/icons/dot';
|
import DotIcon from '@lucide/svelte/icons/dot';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
createDotCrossfade,
|
createDotCrossfade,
|
||||||
getDotTransitionParams,
|
getDotTransitionParams,
|
||||||
@@ -71,6 +73,21 @@ function handleSelect(font: UnifiedFont) {
|
|||||||
comparisonStore.fontB = font;
|
comparisonStore.fontB = font;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true once the font file is loaded (or errored) and safe to render.
|
||||||
|
* Called inside the template — Svelte 5 tracks the $state reads inside
|
||||||
|
* appliedFontsManager.getFontStatus(), so each row re-renders reactively
|
||||||
|
* when its file arrives.
|
||||||
|
*/
|
||||||
|
function isFontReady(font: UnifiedFont): boolean {
|
||||||
|
const status = appliedFontsManager.getFontStatus(
|
||||||
|
font.id,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
font.features?.isVariable,
|
||||||
|
);
|
||||||
|
return status === 'loaded' || status === 'error';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 h-full">
|
<div class="flex-1 min-h-0 h-full">
|
||||||
@@ -83,77 +100,93 @@ function handleSelect(font: UnifiedFont) {
|
|||||||
<FontVirtualList
|
<FontVirtualList
|
||||||
data-font-list
|
data-font-list
|
||||||
weight={DEFAULT_FONT_WEIGHT}
|
weight={DEFAULT_FONT_WEIGHT}
|
||||||
itemHeight={45}
|
itemHeight={44}
|
||||||
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
||||||
>
|
>
|
||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
{#each { length: 12 } as _, i (i)}
|
<div class="py-2.5 md:py-3 px-7">
|
||||||
<div class="w-full px-4 py-3 flex items-center justify-between border border-transparent bg-white/50 dark:bg-[#1e1e1e]/50">
|
{#each { length: 50 } as _, index (index)}
|
||||||
<div class="flex-1 flex items-center gap-3">
|
<div class="w-full px-3 py-3 flex items-center justify-between">
|
||||||
<Skeleton
|
<div class="flex-1 flex items-center gap-3">
|
||||||
class="h-4 bg-neutral-200/70 dark:bg-neutral-800/70"
|
<Skeleton
|
||||||
style="width: {getSkeletonWidth(i)}"
|
class="h-4 w-32 bg-neutral-200/70 dark:bg-neutral-800/70"
|
||||||
/>
|
style="width: {getSkeletonWidth(index)}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet children({ item: font, index })}
|
{#snippet children({ item: font, index })}
|
||||||
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
|
<div class="relative h-[44px] w-full">
|
||||||
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
|
{#if !isFontReady(font)}
|
||||||
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
|
<div
|
||||||
|
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
|
||||||
|
transition:fade={{ duration: 300 }}
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
class="h-4 bg-neutral-200/70 dark:bg-neutral-800/70"
|
||||||
|
style="width: {getSkeletonWidth(index)}"
|
||||||
|
/>
|
||||||
|
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
|
||||||
|
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
|
||||||
|
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
|
||||||
|
|
||||||
<Button
|
<div transition:fade={{ duration: 300 }} class="h-full">
|
||||||
variant="tertiary"
|
<Button
|
||||||
{active}
|
variant="tertiary"
|
||||||
onclick={() => handleSelect(font)}
|
{active}
|
||||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
|
onclick={() => handleSelect(font)}
|
||||||
iconPosition="right"
|
class="w-full h-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
|
||||||
>
|
iconPosition="right"
|
||||||
<FontApplicator {font}>
|
>
|
||||||
{#snippet skeleton()}
|
<FontApplicator {font}>
|
||||||
<Skeleton class="h-4 w-32 bg-neutral-200/70 dark:bg-neutral-800/70" />
|
{font.name}
|
||||||
{/snippet}
|
</FontApplicator>
|
||||||
{font.name}
|
|
||||||
</FontApplicator>
|
|
||||||
|
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
{#if active}
|
{#if active}
|
||||||
<div
|
<div
|
||||||
in:receive={getDotTransitionParams(
|
in:receive={getDotTransitionParams(
|
||||||
'active-dot',
|
'active-dot',
|
||||||
index,
|
index,
|
||||||
prevSide === 'A' ? prevIndexA : prevIndexB,
|
prevSide === 'A' ? prevIndexA : prevIndexB,
|
||||||
)}
|
)}
|
||||||
out:send={getDotTransitionParams(
|
out:send={getDotTransitionParams(
|
||||||
'active-dot',
|
'active-dot',
|
||||||
index,
|
index,
|
||||||
side === 'A' ? indexA : indexB,
|
side === 'A' ? indexA : indexB,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DotIcon class="size-8 stroke-brand" />
|
<DotIcon class="size-8 stroke-brand" />
|
||||||
</div>
|
</div>
|
||||||
{:else if isSelectedA || isSelectedB}
|
{:else if isSelectedA || isSelectedB}
|
||||||
{@const isA = isSelectedA}
|
{@const isA = isSelectedA}
|
||||||
<div
|
<div
|
||||||
in:receive={getDotTransitionParams(
|
in:receive={getDotTransitionParams(
|
||||||
'inactive-dot',
|
'inactive-dot',
|
||||||
index,
|
index,
|
||||||
isA ? prevIndexB : prevIndexA,
|
isA ? prevIndexB : prevIndexA,
|
||||||
)}
|
)}
|
||||||
out:send={getDotTransitionParams(
|
out:send={getDotTransitionParams(
|
||||||
'inactive-dot',
|
'inactive-dot',
|
||||||
index,
|
index,
|
||||||
isA ? indexB : indexA,
|
isA ? indexB : indexA,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
|
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FontVirtualList>
|
</FontVirtualList>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user