Compare commits
9 Commits
c2542026a4
...
1d0ca31262
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0ca31262 | ||
|
|
a5380333eb | ||
|
|
46de3c6e87 | ||
|
|
91300bdc25 | ||
|
|
2ee66316f7 | ||
|
|
c6d20aae3d | ||
|
|
a0f184665d | ||
|
|
d4d2d68d9a | ||
|
|
55a560b785 |
@@ -2,66 +2,83 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
export interface FontConfigRequest {
|
||||
slug: string;
|
||||
weight: number;
|
||||
isVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager that handles loading of the fonts
|
||||
* Adds <link /> tags to <head />
|
||||
* - Uses batch loading to reduce the number of requests
|
||||
* - Uses a queue to prevent too many requests at once
|
||||
* - Purges unused fonts after a certain time
|
||||
* Manager that handles loading of fonts from Fontshare.
|
||||
* Logic:
|
||||
* - Variable fonts: Loaded once per slug (covers all weights).
|
||||
* - Static fonts: Loaded per slug + weight combination.
|
||||
*/
|
||||
class AppliedFontsManager {
|
||||
// Stores: slug -> timestamp of last visibility
|
||||
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
|
||||
#usageTracker = new Map<string, number>();
|
||||
// Stores: slug -> batchId
|
||||
// Map: key -> batchId
|
||||
#slugToBatch = new Map<string, string>();
|
||||
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
||||
// Map: batchId -> HTMLLinkElement
|
||||
#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
|
||||
|
||||
#PURGE_INTERVAL = 60000;
|
||||
#TTL = 5 * 60 * 1000;
|
||||
#CHUNK_SIZE = 3;
|
||||
|
||||
// Reactive status map for UI feedback
|
||||
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.
|
||||
* Resolves a unique key for the font asset.
|
||||
*/
|
||||
touch(slugs: string[]) {
|
||||
#getFontKey(slug: string, weight: number, isVariable: boolean): string {
|
||||
const s = slug.toLowerCase();
|
||||
// Variable fonts only need one entry regardless of weight
|
||||
return isVariable ? s : `${s}@${weight}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when a font is rendered on screen.
|
||||
*/
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
const now = Date.now();
|
||||
const toRegister: string[] = [];
|
||||
|
||||
slugs.forEach(slug => {
|
||||
this.#usageTracker.set(slug, now);
|
||||
if (!this.#slugToBatch.has(slug)) {
|
||||
toRegister.push(slug);
|
||||
configs.forEach(({ slug, weight, isVariable = false }) => {
|
||||
const key = this.#getFontKey(slug, weight, isVariable);
|
||||
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
if (!this.#slugToBatch.has(key)) {
|
||||
toRegister.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
registerFonts(keys: string[]) {
|
||||
const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
|
||||
if (newKeys.length === 0) return;
|
||||
|
||||
newSlugs.forEach(s => this.#queue.add(s));
|
||||
newKeys.forEach(k => this.#queue.add(k));
|
||||
|
||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||
}
|
||||
|
||||
getFontStatus(slug: string) {
|
||||
return this.statuses.get(slug);
|
||||
getFontStatus(slug: string, weight: number, isVariable: boolean) {
|
||||
return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
@@ -76,16 +93,23 @@ class AppliedFontsManager {
|
||||
this.#timeoutId = null;
|
||||
}
|
||||
|
||||
#createBatch(slugs: string[]) {
|
||||
#createBatch(keys: 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('&');
|
||||
|
||||
/**
|
||||
* Fontshare API Logic:
|
||||
* - If key contains '@', it's static (e.g., satoshi@700)
|
||||
* - If it's a plain slug, it's variable. We append '@1,2' for variable assets.
|
||||
*/
|
||||
const query = keys.map(k => {
|
||||
return k.includes('@') ? `f[]=${k}` : `f[]=${k}@1,2`;
|
||||
}).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'));
|
||||
keys.forEach(key => this.statuses.set(key, 'loading'));
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
@@ -94,21 +118,24 @@ class AppliedFontsManager {
|
||||
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}"`)
|
||||
keys.forEach(key => {
|
||||
this.#slugToBatch.set(key, batchId);
|
||||
|
||||
// Determine what to check in the Font Loading API
|
||||
const isVariable = !key.includes('@');
|
||||
const [family, staticWeight] = key.split('@');
|
||||
|
||||
// For variable fonts, we check a standard weight;
|
||||
// for static, we check the specific numeric weight requested.
|
||||
const weightToCheck = isVariable ? '400' : staticWeight;
|
||||
|
||||
document.fonts.load(`${weightToCheck} 1em "${family}"`)
|
||||
.then(loadedFonts => {
|
||||
if (loadedFonts.length > 0) {
|
||||
this.statuses.set(slug, 'loaded');
|
||||
} else {
|
||||
this.statuses.set(slug, 'error');
|
||||
}
|
||||
this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
|
||||
})
|
||||
.catch(() => {
|
||||
this.statuses.set(slug, 'error');
|
||||
this.statuses.set(key, 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -116,31 +143,30 @@ class AppliedFontsManager {
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
const batchesToPotentialDelete = new Set<string>();
|
||||
const slugsToDelete: string[] = [];
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
// Identify expired slugs
|
||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
||||
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
||||
if (now - lastUsed > this.#TTL) {
|
||||
const batchId = this.#slugToBatch.get(slug);
|
||||
const batchId = this.#slugToBatch.get(key);
|
||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
||||
slugsToDelete.push(slug);
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove a batch if ALL fonts in that batch are expired
|
||||
batchesToPotentialDelete.forEach(batchId => {
|
||||
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
||||
const batchKeys = Array.from(this.#slugToBatch.entries())
|
||||
.filter(([_, bId]) => bId === batchId)
|
||||
.map(([slug]) => slug);
|
||||
.map(([key]) => key);
|
||||
|
||||
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
|
||||
const allExpired = batchKeys.every(k => keysToDelete.includes(k));
|
||||
|
||||
if (allExpired) {
|
||||
this.#batchElements.get(batchId)?.remove();
|
||||
this.#batchElements.delete(batchId);
|
||||
batchSlugs.forEach(s => {
|
||||
this.#slugToBatch.delete(s);
|
||||
this.#usageTracker.delete(s);
|
||||
batchKeys.forEach(k => {
|
||||
this.#slugToBatch.delete(k);
|
||||
this.#usageTracker.delete(k);
|
||||
this.statuses.delete(k);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ interface Props {
|
||||
* Font id to load
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Font weight
|
||||
*/
|
||||
weight?: number;
|
||||
/**
|
||||
* Additional classes
|
||||
*/
|
||||
@@ -30,7 +34,7 @@ interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { name, id, className, children }: Props = $props();
|
||||
let { name, id, weight = 400, className, children }: Props = $props();
|
||||
let element: Element;
|
||||
|
||||
// Track if the user has actually scrolled this into view
|
||||
@@ -40,7 +44,7 @@ $effect(() => {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
hasEnteredViewport = true;
|
||||
appliedFontsManager.touch([id]);
|
||||
appliedFontsManager.touch([{ slug: id, weight }]);
|
||||
|
||||
// Once it has entered, we can stop observing to save CPU
|
||||
observer.unobserve(element);
|
||||
@@ -50,7 +54,7 @@ $effect(() => {
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id));
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight, false));
|
||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { ComparisonSlider } from '$shared/ui';
|
||||
import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import PairSelector from '../PairSelector/PairSelector.svelte';
|
||||
|
||||
@@ -10,7 +11,9 @@ const [fontA, fontB] = $derived(displayedFontsStore.selectedPair);
|
||||
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
|
||||
|
||||
$effect(() => {
|
||||
appliedFontsManager.touch(displayedFontsStore.fonts.map(font => font.id));
|
||||
appliedFontsManager.touch(
|
||||
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -22,7 +25,11 @@ $effect(() => {
|
||||
</div>
|
||||
|
||||
{#if fontA && fontB}
|
||||
<ComparisonSlider fontA={fontA} fontB={fontB} text={displayedText} />
|
||||
<ComparisonSlider
|
||||
fontA={fontA}
|
||||
fontB={fontB}
|
||||
bind:text={displayedText}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { ContentEditable } from '$shared/ui';
|
||||
|
||||
interface Props {
|
||||
@@ -31,6 +32,8 @@ let {
|
||||
text = $bindable(),
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
const weight = $derived(controlManager.weight ?? 400);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -39,6 +42,7 @@ let {
|
||||
bg-white p-6 border border-slate-200
|
||||
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
||||
"
|
||||
style:font-weight={weight}
|
||||
>
|
||||
<FontApplicator id={font.id} name={font.name}>
|
||||
<ContentEditable bind:text={text} {...restProps} />
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
type TypographyControl,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface Control {
|
||||
id: string;
|
||||
increaseLabel?: string;
|
||||
decreaseLabel?: string;
|
||||
controlLabel?: string;
|
||||
instance: TypographyControl;
|
||||
}
|
||||
|
||||
export class TypographyControlManager {
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
|
||||
constructor(configs: ControlModel[]) {
|
||||
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
|
||||
this.#controls.set(id, {
|
||||
id,
|
||||
increaseLabel,
|
||||
decreaseLabel,
|
||||
controlLabel,
|
||||
instance: createTypographyControl(config),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get controls() {
|
||||
return this.#controls.values();
|
||||
}
|
||||
|
||||
get weight() {
|
||||
return this.#controls.get('font_weight')?.instance.value ?? 400;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.#controls.get('font_size')?.instance.value;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#controls.get('line_height')?.instance.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typography control manager that handles a collection of typography controls.
|
||||
@@ -10,19 +52,5 @@ import {
|
||||
* @returns - Typography control manager instance.
|
||||
*/
|
||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
||||
const controls = $state(
|
||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
||||
id,
|
||||
increaseLabel,
|
||||
decreaseLabel,
|
||||
controlLabel,
|
||||
instance: createTypographyControl(config),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
get controls() {
|
||||
return controls;
|
||||
},
|
||||
};
|
||||
return new TypographyControlManager(configs);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createCharacterComparison(
|
||||
text: () => string,
|
||||
fontA: () => { name: string; id: string },
|
||||
fontB: () => { name: string; id: string },
|
||||
weight: () => number,
|
||||
size: () => number,
|
||||
) {
|
||||
let lines = $state<LineData[]>([]);
|
||||
let containerWidth = $state(0);
|
||||
@@ -29,14 +31,16 @@ export function createCharacterComparison(
|
||||
* @param text - Text string to measure
|
||||
* @param fontFamily - Font family name
|
||||
* @param fontSize - Font size in pixels
|
||||
* @param fontWeight - Font weight
|
||||
*/
|
||||
function measureText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: number,
|
||||
): number {
|
||||
ctx.font = `bold ${fontSize}px ${fontFamily}`;
|
||||
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
@@ -45,7 +49,13 @@ export function createCharacterComparison(
|
||||
* Matches the Tailwind breakpoints used in the component.
|
||||
*/
|
||||
function getFontSize() {
|
||||
if (typeof window === 'undefined') return 64;
|
||||
// const fontSize = size();
|
||||
// if (fontSize) {
|
||||
// return fontSize;
|
||||
// }
|
||||
if (typeof window === 'undefined') {
|
||||
return 64;
|
||||
}
|
||||
return window.innerWidth >= 1024
|
||||
? 112
|
||||
: window.innerWidth >= 768
|
||||
@@ -62,6 +72,7 @@ export function createCharacterComparison(
|
||||
* @param container - The container element to measure width from
|
||||
* @param measureCanvas - The canvas element used for text measurement
|
||||
*/
|
||||
|
||||
function breakIntoLines(
|
||||
container: HTMLElement | undefined,
|
||||
measureCanvas: HTMLCanvasElement | undefined,
|
||||
@@ -70,15 +81,15 @@ export function createCharacterComparison(
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
containerWidth = rect.width;
|
||||
|
||||
// Padding considerations - matches the container padding
|
||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||
const availableWidth = rect.width - padding;
|
||||
|
||||
const ctx = measureCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const controlledFontSize = size();
|
||||
const fontSize = getFontSize();
|
||||
const currentWeight = weight(); // Get current weight
|
||||
const words = text().split(' ');
|
||||
const newLines: LineData[] = [];
|
||||
let currentLineWords: string[] = [];
|
||||
@@ -86,9 +97,21 @@ export function createCharacterComparison(
|
||||
function pushLine(words: string[]) {
|
||||
if (words.length === 0) return;
|
||||
const lineText = words.join(' ');
|
||||
// Measure width to ensure we know exactly how wide this line renders
|
||||
const widthA = measureText(ctx!, lineText, fontA().name, fontSize);
|
||||
const widthB = measureText(ctx!, lineText, fontB().name, fontSize);
|
||||
// Measure both fonts at the CURRENT weight
|
||||
const widthA = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
fontA().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
);
|
||||
const widthB = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
fontB().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
);
|
||||
const maxWidth = Math.max(widthA, widthB);
|
||||
newLines.push({ text: lineText, width: maxWidth });
|
||||
}
|
||||
@@ -97,10 +120,21 @@ export function createCharacterComparison(
|
||||
const testLine = currentLineWords.length > 0
|
||||
? currentLineWords.join(' ') + ' ' + word
|
||||
: word;
|
||||
|
||||
// Measure with both fonts and use the wider one to prevent layout shifts
|
||||
const widthA = measureText(ctx, testLine, fontA().name, fontSize);
|
||||
const widthB = measureText(ctx, testLine, fontB().name, fontSize);
|
||||
const widthA = measureText(
|
||||
ctx,
|
||||
testLine,
|
||||
fontA().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
);
|
||||
const widthB = measureText(
|
||||
ctx,
|
||||
testLine,
|
||||
fontB().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
);
|
||||
const maxWidth = Math.max(widthA, widthB);
|
||||
|
||||
if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||
@@ -111,10 +145,7 @@ export function createCharacterComparison(
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
}
|
||||
|
||||
if (currentLineWords.length > 0) pushLine(currentLineWords);
|
||||
lines = newLines;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel {
|
||||
/**
|
||||
* Area label for increase button
|
||||
*/
|
||||
increaseLabel: string;
|
||||
increaseLabel?: string;
|
||||
/**
|
||||
* Area label for decrease button
|
||||
*/
|
||||
decreaseLabel: string;
|
||||
decreaseLabel?: string;
|
||||
/**
|
||||
* Control area label
|
||||
*/
|
||||
controlLabel: string;
|
||||
controlLabel?: string;
|
||||
}
|
||||
|
||||
export function createTypographyControl<T extends ControlDataModel>(
|
||||
|
||||
@@ -29,4 +29,5 @@ export {
|
||||
|
||||
export {
|
||||
createCharacterComparison,
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
type EntityStore,
|
||||
type Filter,
|
||||
type FilterModel,
|
||||
type LineData,
|
||||
type Property,
|
||||
type TypographyControl,
|
||||
type VirtualItem,
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import * as ButtonGroup from '$shared/shadcn/ui/button-group';
|
||||
import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import * as Popover from '$shared/shadcn/ui/popover';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { Slider } from '$shared/shadcn/ui/slider';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
@@ -67,30 +71,32 @@ const handleSliderChange = (newValue: number) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<ButtonGroup.Root>
|
||||
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 bg-white/20 border-none"
|
||||
size="icon"
|
||||
aria-label={decreaseLabel}
|
||||
onclick={control.decrease}
|
||||
disabled={control.isAtMin}
|
||||
>
|
||||
<MinusIcon />
|
||||
<MinusIcon class="size-4" />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 bg-white/20 border-none"
|
||||
size="icon"
|
||||
aria-label={controlLabel}
|
||||
>
|
||||
{control.value}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-4">
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Slider
|
||||
min={control.min}
|
||||
@@ -110,15 +116,16 @@ const handleSliderChange = (newValue: number) => {
|
||||
class="w-16 text-center"
|
||||
/>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 bg-white/20 border-none"
|
||||
size="icon"
|
||||
aria-label={increaseLabel}
|
||||
onclick={control.increase}
|
||||
disabled={control.isAtMax}
|
||||
>
|
||||
<PlusIcon />
|
||||
<PlusIcon class="size-4" />
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
</ButtonGroupRoot>
|
||||
|
||||
72
src/shared/ui/ComboControlV2/ComboControlV2.svelte
Normal file
72
src/shared/ui/ComboControlV2/ComboControlV2.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
Component: ComboControl
|
||||
Provides the same functionality as the original ComboControl but lacks increase/decrease buttons.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { Slider } from '$shared/shadcn/ui/slider';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
control: TypographyControl;
|
||||
ref?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
control,
|
||||
ref = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let sliderValue = $state(Number(control.value));
|
||||
|
||||
$effect(() => {
|
||||
sliderValue = Number(control.value);
|
||||
});
|
||||
|
||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
const parsedValue = parseFloat(event.currentTarget.value);
|
||||
if (!isNaN(parsedValue)) {
|
||||
control.value = parsedValue;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSliderChange = (newValue: number) => {
|
||||
control.value = newValue;
|
||||
};
|
||||
|
||||
// Shared glass button class for consistency
|
||||
// const glassBtnClass = cn(
|
||||
// 'border-none transition-all duration-200',
|
||||
// 'bg-white/10 hover:bg-white/40 active:scale-90',
|
||||
// 'text-slate-900 font-medium',
|
||||
// );
|
||||
|
||||
// const ghostStyle = cn(
|
||||
// 'flex items-center justify-center transition-all duration-300 ease-out',
|
||||
// 'text-slate-900/40 hover:text-slate-950 hover:bg-white/20 active:scale-90',
|
||||
// 'disabled:opacity-10 disabled:pointer-events-none',
|
||||
// );
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Input
|
||||
value={control.value}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50"
|
||||
/>
|
||||
<Slider
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={sliderValue}
|
||||
onValueChange={handleSliderChange}
|
||||
type="single"
|
||||
orientation="vertical"
|
||||
class="h-30"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,182 +0,0 @@
|
||||
<!--
|
||||
Component: ComparisonSlider (Ultimate Comparison Slider)
|
||||
|
||||
A multiline text comparison slider that morphs between two fonts.
|
||||
|
||||
Features:
|
||||
- Multiline support with precise line breaking matching container width.
|
||||
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
|
||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts" generics="T extends { name: string; id: string }">
|
||||
import { createCharacterComparison } from '$shared/lib';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
interface Props<T extends { name: string; id: string }> {
|
||||
/** First font definition ({name, id}) */
|
||||
fontA: T;
|
||||
/** Second font definition ({name, id}) */
|
||||
fontB: T;
|
||||
/** Text to display and compare */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
fontA,
|
||||
fontB,
|
||||
text = 'The quick brown fox jumps over the lazy dog',
|
||||
}: Props<T> = $props();
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let isDragging = $state(false);
|
||||
|
||||
/**
|
||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||
* Manages line breaking and character state based on fonts and container dimensions.
|
||||
*/
|
||||
const charComparison = createCharacterComparison(
|
||||
() => text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
);
|
||||
|
||||
/** Physics-based spring for smooth handle movement */
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2, // Balanced for responsiveness
|
||||
damping: 0.7, // No bounce, just smooth stop
|
||||
});
|
||||
const sliderPos = $derived(sliderSpring.current);
|
||||
|
||||
/** Updates spring target based on pointer position */
|
||||
function handleMove(e: PointerEvent) {
|
||||
if (!isDragging || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
sliderSpring.target = percentage;
|
||||
}
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
handleMove(e);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
const stop = () => (isDragging = false);
|
||||
window.addEventListener('pointerup', stop);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Re-run line breaking when container resizes or dependencies change
|
||||
$effect(() => {
|
||||
if (container && measureCanvas) {
|
||||
// Using rAF to ensure DOM is ready/stabilized
|
||||
requestAnimationFrame(() => {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (container && measureCanvas) {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Hidden canvas used for text measurement by the helper -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden bg-white rounded-[2.5rem] border border-slate-100 shadow-2xl select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center"
|
||||
>
|
||||
<!-- Background Gradient Accent -->
|
||||
<div
|
||||
class="
|
||||
absolute inset-0 bg-linear-to-br
|
||||
from-slate-50/50 via-white to-slate-100/50
|
||||
opacity-50 pointer-events-none
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Text Rendering Container -->
|
||||
<div
|
||||
class="relative flex flex-col items-center gap-4 text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15] z-10 pointer-events-none text-center"
|
||||
style:perspective="1000px"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div class="relative w-full whitespace-nowrap">
|
||||
{#each line.text.split('') as char, charIndex}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)}
|
||||
<!--
|
||||
Single Character Span
|
||||
- Font Family switches based on `isPast`
|
||||
- Transitions/Transforms provide the "morph" feel
|
||||
-->
|
||||
<span
|
||||
class="inline-block transition-all duration-300 ease-out will-change-transform"
|
||||
style:font-family={isPast ? fontB.name : fontA.name}
|
||||
style:color={isPast
|
||||
? 'rgb(79, 70, 229)'
|
||||
: 'rgb(15, 23, 42)'}
|
||||
style:transform="
|
||||
scale({1 + proximity * 0.2}) translateY({-proximity *
|
||||
12}px) rotateY({proximity *
|
||||
25 *
|
||||
(isPast ? -1 : 1)}deg)
|
||||
"
|
||||
style:will-change={proximity > 0
|
||||
? 'transform, font-family, color'
|
||||
: 'auto'}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Visual Components -->
|
||||
<SliderLine {sliderPos} />
|
||||
<Labels {fontA} {fontB} {sliderPos} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
span {
|
||||
/*
|
||||
Optimize for performance and smooth transitions.
|
||||
step-end logic is effectively handled by binary font switching in JS.
|
||||
*/
|
||||
transition:
|
||||
font-family 0.15s ease-out,
|
||||
color 0.2s ease-out,
|
||||
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
sliderPos: number;
|
||||
}
|
||||
let { sliderPos }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Vertical Divider & Knobs -->
|
||||
<div
|
||||
class="absolute top-0 bottom-0 z-30 pointer-events-none"
|
||||
style:left="{sliderPos}%"
|
||||
>
|
||||
<!-- Vertical Line -->
|
||||
<div class="absolute inset-y-0 -left-px w-0.5 bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]">
|
||||
</div>
|
||||
|
||||
<!-- Top Knob -->
|
||||
<div class="absolute top-6 left-0 -translate-x-1/2">
|
||||
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Knob -->
|
||||
<div class="absolute bottom-6 left-0 -translate-x-1/2">
|
||||
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
|
||||
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
|
||||
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||
@@ -14,7 +14,7 @@ import VirtualList from './VirtualList/VirtualList.svelte';
|
||||
export {
|
||||
CheckboxFilter,
|
||||
ComboControl,
|
||||
ComparisonSlider,
|
||||
ComboControlV2,
|
||||
ContentEditable,
|
||||
SearchBar,
|
||||
VirtualList,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<!--
|
||||
Component: ComparisonSlider (Ultimate Comparison Slider)
|
||||
|
||||
A multiline text comparison slider that morphs between two fonts.
|
||||
|
||||
Features:
|
||||
- Multiline support with precise line breaking matching container width.
|
||||
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
|
||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts" generics="T extends { name: string; id: string }">
|
||||
import {
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { LineData } from '$shared/lib';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
interface Props<T extends { name: string; id: string }> {
|
||||
/**
|
||||
* First font definition ({name, id})
|
||||
*/
|
||||
fontA: T;
|
||||
/**
|
||||
* Second font definition ({name, id})
|
||||
*/
|
||||
fontB: T;
|
||||
/**
|
||||
* Text to display and compare
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
fontA,
|
||||
fontB,
|
||||
text = $bindable('The quick brown fox jumps over the lazy dog'),
|
||||
}: Props<T> = $props();
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let isDragging = $state(false);
|
||||
|
||||
const weightControl = createTypographyControl({
|
||||
min: 100,
|
||||
max: 700,
|
||||
step: 100,
|
||||
value: 400,
|
||||
});
|
||||
|
||||
const heightControl = createTypographyControl({
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
value: 1.2,
|
||||
});
|
||||
|
||||
const sizeControl = createTypographyControl({
|
||||
min: 1,
|
||||
max: 112,
|
||||
step: 1,
|
||||
value: 64,
|
||||
});
|
||||
|
||||
/**
|
||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||
* Manages line breaking and character state based on fonts and container dimensions.
|
||||
*/
|
||||
const charComparison = createCharacterComparison(
|
||||
() => text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => weightControl.value,
|
||||
() => sizeControl.value,
|
||||
);
|
||||
|
||||
/** Physics-based spring for smooth handle movement */
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2, // Balanced for responsiveness
|
||||
damping: 0.7, // No bounce, just smooth stop
|
||||
});
|
||||
const sliderPos = $derived(sliderSpring.current);
|
||||
|
||||
/** Updates spring target based on pointer position */
|
||||
function handleMove(e: PointerEvent) {
|
||||
if (!isDragging || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
sliderSpring.target = percentage;
|
||||
}
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
|
||||
console.log('Pointer down on controls wrapper');
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
handleMove(e);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
const stop = () => (isDragging = false);
|
||||
window.addEventListener('pointerup', stop);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Re-run line breaking when container resizes or dependencies change
|
||||
$effect(() => {
|
||||
// React on text and typography settings changes
|
||||
const _text = text;
|
||||
const _weight = weightControl.value;
|
||||
const _size = sizeControl.value;
|
||||
const _height = heightControl.value;
|
||||
|
||||
if (container && measureCanvas && fontA && fontB) {
|
||||
// Using rAF to ensure DOM is ready/stabilized
|
||||
requestAnimationFrame(() => {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (
|
||||
container && measureCanvas
|
||||
) {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet renderLine(line: LineData, lineIndex: number)}
|
||||
<div
|
||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:line-height={`${heightControl.value}em`}
|
||||
>
|
||||
{#each line.text.split('') as char, charIndex}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)}
|
||||
<!--
|
||||
Single Character Span
|
||||
- Font Family switches based on `isPast`
|
||||
- Transitions/Transforms provide the "morph" feel
|
||||
-->
|
||||
<CharacterSlot
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
weight={weightControl.value}
|
||||
size={sizeControl.value}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Hidden canvas used for text measurement by the helper -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden
|
||||
bg-indigo-50 rounded-[2.5rem] border border-slate-100 shadow-2xl
|
||||
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
|
||||
"
|
||||
class:box-shadow={'-20px 20px 60px #bebebe, 20px -20px 60px #ffffff;'}
|
||||
>
|
||||
<!-- Background Gradient Accent -->
|
||||
<div
|
||||
class="
|
||||
absolute inset-0 bg-linear-to-br
|
||||
from-slate-50/50 via-white to-slate-100/50
|
||||
opacity-50 pointer-events-none
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Text Rendering Container -->
|
||||
<div
|
||||
class="
|
||||
relative flex flex-col items-center gap-4
|
||||
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
|
||||
z-10 pointer-events-none text-center
|
||||
"
|
||||
style:perspective="1000px"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Visual Components -->
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
<Labels {fontA} {fontB} {sliderPos} />
|
||||
</div>
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<ControlsWrapper
|
||||
bind:wrapper={controlsWrapperElement}
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:text={text}
|
||||
containerWidth={container?.clientWidth}
|
||||
weightControl={weightControl}
|
||||
sizeControl={sizeControl}
|
||||
heightControl={heightControl}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
Component: CharacterSlot
|
||||
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Displayed character
|
||||
*/
|
||||
char: string;
|
||||
/**
|
||||
* Proximity of the character to the center of the slider
|
||||
*/
|
||||
proximity: number;
|
||||
/**
|
||||
* Flag indicating whether character needed to be changed
|
||||
*/
|
||||
isPast: boolean;
|
||||
/**
|
||||
* Font weight of the character
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Font size of the character
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Name of the font for the character after the change
|
||||
*/
|
||||
fontAName: string;
|
||||
/**
|
||||
* Name of the font for the character before the change
|
||||
*/
|
||||
fontBName: string;
|
||||
}
|
||||
|
||||
let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-block transition-all duration-300 ease-out will-change-transform',
|
||||
isPast ? 'text-indigo-500' : 'text-neutral-950',
|
||||
)}
|
||||
style:font-family={isPast ? fontBName : fontAName}
|
||||
style:font-weight={weight}
|
||||
style:font-size={`${size}px`}
|
||||
style:transform="
|
||||
scale({1 + proximity * 0.2})
|
||||
translateY({-proximity * 12}px)
|
||||
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
|
||||
"
|
||||
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
|
||||
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'}
|
||||
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
/*
|
||||
Optimize for performance and smooth transitions.
|
||||
step-end logic is effectively handled by binary font switching in JS.
|
||||
*/
|
||||
transition:
|
||||
font-family 0.15s ease-out,
|
||||
color 0.2s ease-out,
|
||||
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { ComboControlV2 } from '$shared/ui';
|
||||
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
wrapper?: HTMLDivElement | null;
|
||||
sliderPos: number;
|
||||
isDragging: boolean;
|
||||
text: string;
|
||||
containerWidth: number;
|
||||
weightControl: TypographyControl;
|
||||
sizeControl: TypographyControl;
|
||||
heightControl: TypographyControl;
|
||||
}
|
||||
|
||||
let {
|
||||
sliderPos,
|
||||
isDragging,
|
||||
wrapper = $bindable(null),
|
||||
text = $bindable(),
|
||||
containerWidth = 0,
|
||||
weightControl,
|
||||
sizeControl,
|
||||
heightControl,
|
||||
}: Props = $props();
|
||||
|
||||
let panelWidth = $state(0);
|
||||
const margin = 24;
|
||||
let side = $state<'left' | 'right'>('left');
|
||||
|
||||
// Unified active state for the entire wrapper
|
||||
let isActive = $state(false);
|
||||
|
||||
function handleWrapperClick() {
|
||||
if (!isDragging) {
|
||||
isActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (wrapper && !wrapper.contains(e.target as Node)) {
|
||||
isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
isActive = true;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleWrapperClick();
|
||||
}
|
||||
}
|
||||
|
||||
// Movement Logic
|
||||
$effect(() => {
|
||||
if (containerWidth === 0 || panelWidth === 0) return;
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
const buffer = 40;
|
||||
const leftTrigger = margin + panelWidth + buffer;
|
||||
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
|
||||
|
||||
if (side === 'left' && sliderX < leftTrigger) {
|
||||
side = 'right';
|
||||
} else if (side === 'right' && sliderX > rightTrigger) {
|
||||
side = 'left';
|
||||
}
|
||||
});
|
||||
|
||||
// The "Dodge"
|
||||
const xSpring = new Spring(0, {
|
||||
stiffness: 0.14, // Lower is slower
|
||||
damping: 0.5, // Settle
|
||||
});
|
||||
|
||||
// The "Focus"
|
||||
const ySpring = new Spring(0, {
|
||||
stiffness: 0.32,
|
||||
damping: 0.65,
|
||||
});
|
||||
|
||||
// The "Rise"
|
||||
const scaleSpring = new Spring(1, {
|
||||
stiffness: 0.32,
|
||||
damping: 0.65,
|
||||
});
|
||||
|
||||
// The "Lean"
|
||||
const rotateSpring = new Spring(0, {
|
||||
stiffness: 0.12,
|
||||
damping: 0.55,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
|
||||
if (containerWidth > 0 && panelWidth > 0 && !isActive) {
|
||||
// On side change set the position and the rotation
|
||||
xSpring.target = targetX;
|
||||
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
|
||||
|
||||
setTimeout(() => {
|
||||
rotateSpring.target = 0;
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
|
||||
// Elevation and scale on focus and mouse over
|
||||
$effect(() => {
|
||||
if (isActive && !isDragging) {
|
||||
// Lift up
|
||||
ySpring.target = 8;
|
||||
// Slightly bigger
|
||||
scaleSpring.target = 1.1;
|
||||
|
||||
rotateSpring.target = side === 'right' ? -1.1 : 1.1;
|
||||
|
||||
setTimeout(() => {
|
||||
rotateSpring.target = 0;
|
||||
scaleSpring.target = 1.05;
|
||||
}, 300);
|
||||
} else {
|
||||
ySpring.target = 0;
|
||||
scaleSpring.target = 1;
|
||||
rotateSpring.target = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
isActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside handler
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
onclick={handleWrapperClick}
|
||||
bind:this={wrapper}
|
||||
bind:clientWidth={panelWidth}
|
||||
class={cn(
|
||||
'absolute top-6 left-6 z-50 will-change-transform transition-opacity duration-300 flex items-top gap-1.5',
|
||||
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
style:pointer-events={isDragging ? 'none' : 'auto'}
|
||||
style:transform="
|
||||
translate({xSpring.current}px, {ySpring.current}px)
|
||||
scale({scaleSpring.current})
|
||||
rotateZ({rotateSpring.current}deg)
|
||||
"
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onkeydown={handleKeyDown}
|
||||
aria-label="Font controls"
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
'animate-nudge relative transition-all',
|
||||
side === 'left' ? 'order-2' : 'order-0',
|
||||
isActive ? 'opacity-0' : 'opacity-100',
|
||||
isDragging && 'opacity-40 grayscale-[0.2]',
|
||||
)}
|
||||
>
|
||||
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5',
|
||||
isActive
|
||||
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||
isDragging && 'opacity-40 grayscale-[0.2]',
|
||||
)}
|
||||
style:backdrop-filter="blur(24px)"
|
||||
>
|
||||
<div class="relative px-2 py-1">
|
||||
<Input
|
||||
bind:value={text}
|
||||
disabled={isDragging}
|
||||
onfocusin={handleInputFocus}
|
||||
class={cn(
|
||||
isActive
|
||||
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
|
||||
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
|
||||
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
|
||||
)}
|
||||
placeholder="Edit label..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isActive}
|
||||
<div
|
||||
in:slide={{ duration: 250, delay: 50 }}
|
||||
out:slide={{ duration: 250 }}
|
||||
class="flex justify-between items-center-safe"
|
||||
>
|
||||
<ComboControlV2 control={weightControl} />
|
||||
<ComboControlV2 control={sizeControl} />
|
||||
<ComboControlV2 control={heightControl} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes nudge {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
2% {
|
||||
transform: translateY(-2px) scale(1.1) rotate(-1deg);
|
||||
}
|
||||
4% {
|
||||
transform: translateY(0) scale(1) rotate(1deg);
|
||||
}
|
||||
6% {
|
||||
transform: translateY(-2px) scale(1.1) rotate(0deg);
|
||||
}
|
||||
8% {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-nudge {
|
||||
animation: nudge 10s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
sliderPos: number;
|
||||
isDragging: boolean;
|
||||
}
|
||||
let { sliderPos, isDragging }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-y-0 pointer-events-none -translate-x-1/2 z-30"
|
||||
style:left="{sliderPos}%"
|
||||
>
|
||||
<!-- Subtle wave glow zone -->
|
||||
<div
|
||||
class={cn(
|
||||
'absolute inset-y-0 w-24 -left-12 bg-linear-to-r from-transparent via-indigo-500/8 to-transparent transition-all duration-300',
|
||||
isDragging ? 'via-indigo-500/12' : '',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Vertical divider line -->
|
||||
<div
|
||||
class="absolute inset-y-0 w-0.5 bg-linear-to-b from-indigo-400/30 via-indigo-500 to-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.5)] transition-shadow duration-200"
|
||||
class:shadow-[0_0_20px_rgba(99,102,241,0.7)]={isDragging}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Top knob -->
|
||||
<div
|
||||
class="absolute top-6 left-0 -translate-x-1/2 transition-transform duration-200"
|
||||
class:scale-125={isDragging}
|
||||
>
|
||||
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom knob -->
|
||||
<div
|
||||
class="absolute bottom-6 left-0 -translate-x-1/2 transition-transform duration-200"
|
||||
class:scale-125={isDragging}
|
||||
>
|
||||
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<style>
|
||||
|
||||
div {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
</style>
|
||||
-->
|
||||
3
src/widgets/ComparisonSlider/ui/index.ts
Normal file
3
src/widgets/ComparisonSlider/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
|
||||
|
||||
export { ComparisonSlider };
|
||||
Reference in New Issue
Block a user