Compare commits
23 Commits
32da012b26
...
20f6e193f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f6e193f2 | ||
|
|
c04518300b | ||
|
|
ee074036f6 | ||
|
|
ba883ef9a8 | ||
|
|
28a71452d1 | ||
|
|
b7ce100407 | ||
|
|
96b26fb055 | ||
|
|
5ef8d609ab | ||
|
|
f457e5116f | ||
|
|
e0e0d929bb | ||
|
|
37ab7f795e | ||
|
|
af2ef77c30 | ||
|
|
ad18a19c4b | ||
|
|
ef259c6fce | ||
|
|
5d23a2af55 | ||
|
|
df8eca6ef2 | ||
|
|
7e62acce49 | ||
|
|
86e7b2c1ec | ||
|
|
da0612942c | ||
|
|
0444f8c114 | ||
|
|
6b4e0dbbd0 | ||
|
|
7389ec779d | ||
|
|
4d04761d88 |
@@ -24,6 +24,8 @@ let { children } = $props();
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
</svelte:head>
|
||||
|
||||
<div id="app-root">
|
||||
|
||||
@@ -60,9 +60,11 @@ export type {
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontshareStore,
|
||||
fetchFontshareFontsQuery,
|
||||
fontshareStore,
|
||||
selectedFontsStore,
|
||||
} from './model';
|
||||
|
||||
// Stores
|
||||
@@ -72,4 +74,8 @@ export {
|
||||
} from './model/services/fetchGoogleFonts.svelte';
|
||||
|
||||
// UI elements
|
||||
export { FontList } from './ui';
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
@@ -37,7 +37,9 @@ export type {
|
||||
export { fetchFontshareFontsQuery } from './services';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontshareStore,
|
||||
type FontshareStore,
|
||||
fontshareStore,
|
||||
selectedFontsStore,
|
||||
} from './store';
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
class AppliedFontsManager {
|
||||
// Stores: slug -> timestamp of last visibility
|
||||
#usageTracker = new Map<string, number>();
|
||||
// Stores: slug -> batchId
|
||||
#slugToBatch = new Map<string, string>();
|
||||
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
||||
#batchElements = new Map<string, HTMLLinkElement>();
|
||||
|
||||
#queue = new Set<string>();
|
||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
#PURGE_INTERVAL = 60000; // Check every minute
|
||||
#TTL = 5 * 60 * 1000; // 5 minutes
|
||||
#CHUNK_SIZE = 3;
|
||||
|
||||
statuses = new SvelteMap<string, FontStatus>();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Start the "Janitor" loop
|
||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the 'last seen' timestamp for fonts.
|
||||
* Prevents them from being purged while they are on screen.
|
||||
*/
|
||||
touch(slugs: string[]) {
|
||||
const now = Date.now();
|
||||
const toRegister: string[] = [];
|
||||
|
||||
slugs.forEach(slug => {
|
||||
this.#usageTracker.set(slug, now);
|
||||
if (!this.#slugToBatch.has(slug)) {
|
||||
toRegister.push(slug);
|
||||
}
|
||||
});
|
||||
|
||||
if (toRegister.length > 0) this.registerFonts(toRegister);
|
||||
}
|
||||
|
||||
registerFonts(slugs: string[]) {
|
||||
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
||||
if (newSlugs.length === 0) return;
|
||||
|
||||
newSlugs.forEach(s => this.#queue.add(s));
|
||||
|
||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||
}
|
||||
|
||||
getFontStatus(slug: string) {
|
||||
return this.statuses.get(slug);
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
const fullQueue = Array.from(this.#queue);
|
||||
if (fullQueue.length === 0) return;
|
||||
|
||||
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
||||
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
||||
}
|
||||
|
||||
this.#queue.clear();
|
||||
this.#timeoutId = null;
|
||||
}
|
||||
|
||||
#createBatch(slugs: string[]) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const batchId = crypto.randomUUID();
|
||||
// font-display=swap included for better UX
|
||||
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
|
||||
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
||||
|
||||
// Mark all as loading immediately
|
||||
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.dataset.batchId = batchId;
|
||||
document.head.appendChild(link);
|
||||
|
||||
this.#batchElements.set(batchId, link);
|
||||
slugs.forEach(slug => {
|
||||
this.#slugToBatch.set(slug, batchId);
|
||||
|
||||
// Use the Native Font Loading API
|
||||
// format: "font-size font-family"
|
||||
document.fonts.load(`1em "${slug}"`)
|
||||
.then(loadedFonts => {
|
||||
if (loadedFonts.length > 0) {
|
||||
this.statuses.set(slug, 'loaded');
|
||||
} else {
|
||||
this.statuses.set(slug, 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.statuses.set(slug, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
const batchesToPotentialDelete = new Set<string>();
|
||||
const slugsToDelete: string[] = [];
|
||||
|
||||
// Identify expired slugs
|
||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
||||
if (now - lastUsed > this.#TTL) {
|
||||
const batchId = this.#slugToBatch.get(slug);
|
||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
||||
slugsToDelete.push(slug);
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove a batch if ALL fonts in that batch are expired
|
||||
batchesToPotentialDelete.forEach(batchId => {
|
||||
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
||||
.filter(([_, bId]) => bId === batchId)
|
||||
.map(([slug]) => slug);
|
||||
|
||||
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
|
||||
|
||||
if (allExpired) {
|
||||
this.#batchElements.get(batchId)?.remove();
|
||||
this.#batchElements.delete(batchId);
|
||||
batchSlugs.forEach(s => {
|
||||
this.#slugToBatch.delete(s);
|
||||
this.#usageTracker.delete(s);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const appliedFontsManager = new AppliedFontsManager();
|
||||
@@ -17,3 +17,7 @@ export {
|
||||
type FontshareStore,
|
||||
fontshareStore,
|
||||
} from './fontshareStore.svelte';
|
||||
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createEntityStore } from '$shared/lib';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
|
||||
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);
|
||||
46
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
46
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
Component: FontApplicator
|
||||
|
||||
Loads fonts from fontshare with link tag
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
appliedFontsManager,
|
||||
} from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
id: string;
|
||||
className?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { name, id, className, children }: Props = $props();
|
||||
let element: Element;
|
||||
|
||||
$effect(() => {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
appliedFontsManager.touch([id]);
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const isLoading = $derived(appliedFontsManager.getFontStatus(id) === 'loading');
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
style:font-family={name}
|
||||
class={cn(
|
||||
'transition-all duration-200 ease-out',
|
||||
isLoading ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,32 +0,0 @@
|
||||
<!--
|
||||
Component: FontList
|
||||
|
||||
- Displays a virtualized list of fonts with loading, empty, and error states.
|
||||
- Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import FontView from '$features/ShowFont/ui/FontView.svelte';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
Title as ItemTitle,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import { fontshareStore } from '../../model';
|
||||
|
||||
$inspect(fontshareStore.fonts);
|
||||
</script>
|
||||
|
||||
<VirtualList items={fontshareStore.fonts}>
|
||||
{#snippet children({ item: font })}
|
||||
<ItemRoot>
|
||||
<ItemContent>
|
||||
<!-- <ItemTitle></ItemTitle> -->
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{font.provider} • {font.category}
|
||||
</span>
|
||||
<FontView id={font.id} slug={font.id} name={font.name}>{font.name}</FontView>
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
81
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
81
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<!--
|
||||
Component: FontListItem
|
||||
Displays a font item with a checkbox and its characteristics in badges.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
selectedFontsStore,
|
||||
} from '../../model';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
interface Props {
|
||||
font: UnifiedFont;
|
||||
}
|
||||
|
||||
const { font }: Props = $props();
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
selectedFontsStore.addOne(font);
|
||||
} else {
|
||||
selectedFontsStore.removeOne(font.id);
|
||||
}
|
||||
};
|
||||
|
||||
const selected = $derived(selectedFontsStore.has(font.id));
|
||||
</script>
|
||||
|
||||
<div class="pb-1">
|
||||
<Label
|
||||
for={font.id}
|
||||
class="
|
||||
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
|
||||
active:scale-[0.98] active:transition-transform active:duration-75
|
||||
has-aria-checked:border-blue-600
|
||||
has-aria-checked:bg-blue-50
|
||||
dark:has-aria-checked:border-blue-900
|
||||
dark:has-aria-checked:bg-blue-950
|
||||
"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row gap-1 w-full items-center justify-between">
|
||||
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
|
||||
<div class="flex flex-row gap-1">
|
||||
<Badge variant="outline" class="text-[0.5rem]">
|
||||
{font.provider}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="text-[0.5rem]">
|
||||
{font.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<FontApplicator
|
||||
id={font.id}
|
||||
className="text-2xl"
|
||||
name={font.name}
|
||||
>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
id={font.id}
|
||||
checked={selected}
|
||||
onCheckedChange={handleChange}
|
||||
class="
|
||||
transition-all duration-150 ease-out
|
||||
data-[state=checked]:scale-100
|
||||
data-[state=checked]:border-blue-600
|
||||
data-[state=checked]:bg-blue-600
|
||||
data-[state=checked]:text-white
|
||||
dark:data-[state=checked]:border-blue-700
|
||||
dark:data-[state=checked]:bg-blue-700
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
30
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
30
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" generics="T extends { id: string }">
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { appliedFontsManager } from '../../model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
}
|
||||
|
||||
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
// Auto-register fonts with the manager
|
||||
const slugs = visibleItems.map(item => item.id);
|
||||
appliedFontsManager.registerFonts(slugs);
|
||||
|
||||
// Forward the call to any external listener
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
</script>
|
||||
|
||||
<VirtualList
|
||||
{items}
|
||||
{...rest}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
@@ -1,3 +1,9 @@
|
||||
import FontList from './FontList/FontList.svelte';
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontListItem from './FontListItem/FontListItem.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export { FontList };
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
0
src/features/DisplayFont/index.ts
Normal file
0
src/features/DisplayFont/index.ts
Normal file
1
src/features/DisplayFont/model/index.ts
Normal file
1
src/features/DisplayFont/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { displayedFontsStore } from './store';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { selectedFontsStore } from '$entities/Font';
|
||||
|
||||
export class DisplayedFontsStore {
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
|
||||
#displayedFonts = $derived.by(() => {
|
||||
return selectedFontsStore.all;
|
||||
});
|
||||
|
||||
get fonts() {
|
||||
return this.#displayedFonts;
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.#sampleText;
|
||||
}
|
||||
|
||||
set text(text: string) {
|
||||
this.#sampleText = text;
|
||||
}
|
||||
}
|
||||
|
||||
export const displayedFontsStore = new DisplayedFontsStore();
|
||||
1
src/features/DisplayFont/model/store/index.ts
Normal file
1
src/features/DisplayFont/model/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
||||
14
src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
Normal file
14
src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
Component: FontDisplay
|
||||
Displays a grid of FontSampler components for each displayed font.
|
||||
-->
|
||||
<script>
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
||||
{#each displayedFontsStore.fonts as font (font.id)}
|
||||
<FontSampler font={font} bind:text={displayedFontsStore.text} />
|
||||
{/each}
|
||||
</div>
|
||||
37
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
37
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
Component: FontSampler
|
||||
Displays a sample text with a given font in a contenteditable element.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { ContentEditable } from '$shared/ui';
|
||||
|
||||
interface Props {
|
||||
font: UnifiedFont;
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
font,
|
||||
text = $bindable(),
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="
|
||||
w-full rounded-xl
|
||||
bg-white p-6 border border-slate-200
|
||||
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
||||
"
|
||||
>
|
||||
<FontApplicator id={font.id} name={font.name}>
|
||||
<ContentEditable bind:text={text} {...restProps} />
|
||||
</FontApplicator>
|
||||
</div>
|
||||
@@ -4,14 +4,12 @@
|
||||
Combines search input with font list display
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontList,
|
||||
fontshareStore,
|
||||
} from '$entities/Font';
|
||||
import { fontshareStore } from '$entities/Font';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { mapManagerToParams } from '../../lib';
|
||||
import { filterManager } from '../../model';
|
||||
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
@@ -31,5 +29,5 @@ onMount(() => {
|
||||
placeholder="Search fonts by name..."
|
||||
bind:value={filterManager.queryValue}
|
||||
>
|
||||
<FontList />
|
||||
<SuggestedFonts />
|
||||
</SearchBar>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
fontshareStore,
|
||||
} from '$entities/Font';
|
||||
</script>
|
||||
|
||||
<FontVirtualList items={fontshareStore.fonts}>
|
||||
{#snippet children({ item: font })}
|
||||
<FontListItem {font} />
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
@@ -1,40 +0,0 @@
|
||||
<!--
|
||||
Component: FontView
|
||||
|
||||
Loads fonts from fontshare with link tag
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
slug: string;
|
||||
id: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { name, slug, id, children }: Props = $props();
|
||||
let isLoaded = $state(false);
|
||||
|
||||
// Construct the Fontshare API CSS URL
|
||||
// We specify the weight (400) or 'all'
|
||||
const cssUrl = $derived(`https://api.fontshare.com/v2/css?f[]=${id}&display=swap`);
|
||||
|
||||
$effect(() => {
|
||||
// Even though we use a link tag, we can still "watch"
|
||||
// for the font to be ready for a smooth fade-in
|
||||
document.fonts.load(`1em "${name}"`).then(() => {
|
||||
isLoaded = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="stylesheet" href={cssUrl} />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
style:--f={name}
|
||||
style:font-family={name ? `'${name}', sans-serif` : 'inherit'}
|
||||
class="transition-opacity duration-500 {isLoaded ? 'font-[var(--f)] opacity-100' : 'font-sans opacity-0'}"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||
|
||||
/**
|
||||
* Page Component
|
||||
*/
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
<div />
|
||||
<div class="p-2">
|
||||
<FontDisplay />
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,6 @@ const isBrowser = typeof window !== 'undefined';
|
||||
class MotionPreference {
|
||||
// Reactive state
|
||||
#reduced = $state(false);
|
||||
#mediaQuery: MediaQueryList = new MediaQueryList();
|
||||
|
||||
private handleChange = (e: MediaQueryListEvent) => {
|
||||
this.#reduced = e.matches;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (isBrowser) {
|
||||
@@ -17,9 +12,12 @@ class MotionPreference {
|
||||
// Set initial value immediately
|
||||
this.#reduced = mediaQuery.matches;
|
||||
|
||||
mediaQuery.addEventListener('change', this.handleChange);
|
||||
// Simple listener that updates the reactive state
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
this.#reduced = e.matches;
|
||||
};
|
||||
|
||||
this.#mediaQuery = mediaQuery;
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +25,6 @@ class MotionPreference {
|
||||
get reduced() {
|
||||
return this.#reduced;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#mediaQuery.removeEventListener('change', this.handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single instance to be used everywhere
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface Entity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte 5 Entity Store
|
||||
* Uses SvelteMap for O(1) lookups and granular reactivity.
|
||||
*/
|
||||
export class EntityStore<T extends Entity> {
|
||||
// SvelteMap is a reactive version of the native Map
|
||||
#entities = new SvelteMap<string, T>();
|
||||
|
||||
constructor(initialEntities: T[] = []) {
|
||||
this.setAll(initialEntities);
|
||||
}
|
||||
|
||||
// --- Selectors (Equivalent to Selectors) ---
|
||||
|
||||
/** Get all entities as an array */
|
||||
get all() {
|
||||
return Array.from(this.#entities.values());
|
||||
}
|
||||
|
||||
/** Select a single entity by ID */
|
||||
getById(id: string) {
|
||||
return this.#entities.get(id);
|
||||
}
|
||||
|
||||
/** Select multiple entities by IDs */
|
||||
getByIds(ids: string[]) {
|
||||
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
|
||||
}
|
||||
|
||||
// --- Actions (CRUD) ---
|
||||
|
||||
addOne(entity: T) {
|
||||
this.#entities.set(entity.id, entity);
|
||||
}
|
||||
|
||||
addMany(entities: T[]) {
|
||||
entities.forEach(e => this.addOne(e));
|
||||
}
|
||||
|
||||
updateOne(id: string, changes: Partial<T>) {
|
||||
const entity = this.#entities.get(id);
|
||||
if (entity) {
|
||||
// In Svelte 5, updating the object property directly is reactive
|
||||
// if the object itself was made reactive, but here we replace
|
||||
// the reference to ensure top-level map triggers.
|
||||
this.#entities.set(id, { ...entity, ...changes });
|
||||
}
|
||||
}
|
||||
|
||||
removeOne(id: string) {
|
||||
this.#entities.delete(id);
|
||||
}
|
||||
|
||||
removeMany(ids: string[]) {
|
||||
ids.forEach(id => this.#entities.delete(id));
|
||||
}
|
||||
|
||||
setAll(entities: T[]) {
|
||||
this.#entities.clear();
|
||||
this.addMany(entities);
|
||||
}
|
||||
|
||||
has(id: string) {
|
||||
return this.#entities.has(id);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#entities.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
|
||||
return new EntityStore<T>(initialEntities);
|
||||
}
|
||||
@@ -79,7 +79,11 @@ export interface VirtualizerOptions {
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
|
||||
export function createVirtualizer<T>(
|
||||
optionsGetter: () => VirtualizerOptions & {
|
||||
data: T[];
|
||||
},
|
||||
) {
|
||||
let scrollOffset = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let measuredSizes = $state<Record<number, number>>({});
|
||||
@@ -149,6 +153,7 @@ export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & {
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
// Svelte Actions (The DOM Interface)
|
||||
|
||||
@@ -20,3 +20,9 @@ export {
|
||||
} from './createVirtualizer/createVirtualizer.svelte';
|
||||
|
||||
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
||||
|
||||
export {
|
||||
createEntityStore,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
@@ -2,9 +2,12 @@ export {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
createDebouncedState,
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
type Filter,
|
||||
type FilterModel,
|
||||
type Property,
|
||||
@@ -15,3 +18,4 @@ export {
|
||||
} from './helpers';
|
||||
|
||||
export { motion } from './accessibility/motion.svelte';
|
||||
export { splitArray } from './utils';
|
||||
|
||||
@@ -11,3 +11,4 @@ export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||
export { splitArray } from './splitArray/splitArray';
|
||||
|
||||
8
src/shared/lib/utils/splitArray/splitArray.ts
Normal file
8
src/shared/lib/utils/splitArray/splitArray.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
||||
return array.reduce<[T[], T[]]>(
|
||||
([pass, fail], item) => (
|
||||
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
|
||||
),
|
||||
[[], []],
|
||||
);
|
||||
}
|
||||
62
src/shared/ui/ContentEditable/ContentEditable.svelte
Normal file
62
src/shared/ui/ContentEditable/ContentEditable.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
Component: ContentEditable
|
||||
Provides a contenteditable div with custom font and text properties.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/**
|
||||
* Visible text
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Font settings
|
||||
*/
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
text = $bindable('The quick brown fox jumps over the lazy dog.'),
|
||||
fontSize = 48,
|
||||
lineHeight = 1.2,
|
||||
letterSpacing = 0,
|
||||
}: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Initial Sync: Set the text ONLY ONCE when the element is created.
|
||||
// This prevents Svelte from "owning" the innerHTML/innerText.
|
||||
$effect(() => {
|
||||
if (element && element.innerText !== text) {
|
||||
element.innerText = text;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle changes: Update the outer state without re-rendering the div.
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
// Update the bindable prop directly
|
||||
text = target.innerText;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
contenteditable="plaintext-only"
|
||||
spellcheck="false"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
data-placeholder="Type something to test..."
|
||||
oninput={handleInput}
|
||||
class="
|
||||
w-full min-h-[1.2em] outline-none transition-all duration-200
|
||||
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
|
||||
selection:bg-indigo-100 selection:text-indigo-900
|
||||
caret-indigo-500
|
||||
"
|
||||
style:font-size="{fontSize}px"
|
||||
style:line-height={lineHeight}
|
||||
style:letter-spacing="{letterSpacing}em"
|
||||
>
|
||||
</div>
|
||||
@@ -35,6 +35,11 @@ interface Props {
|
||||
* (follows shadcn convention for className prop)
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* An optional callback that will be called for each new set of loaded items
|
||||
* @param items - Loaded items
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
@@ -50,7 +55,8 @@ interface Props {
|
||||
children: Snippet<[{ item: T; index: number }]>;
|
||||
}
|
||||
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
|
||||
Props = $props();
|
||||
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
count: items.length,
|
||||
@@ -58,12 +64,17 @@ const virtualizer = createVirtualizer(() => ({
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
overscan,
|
||||
}));
|
||||
|
||||
$effect(() => {
|
||||
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:virtualizer.container
|
||||
class={cn(
|
||||
'relative overflow-auto border rounded-md bg-background',
|
||||
'relative overflow-auto rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||
|
||||
export {
|
||||
CheckboxFilter,
|
||||
ComboControl,
|
||||
ContentEditable,
|
||||
SearchBar,
|
||||
VirtualList,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user