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 type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
export interface FontConfigRequest {
|
||||||
|
slug: string;
|
||||||
|
weight: number;
|
||||||
|
isVariable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of the fonts
|
* Manager that handles loading of fonts from Fontshare.
|
||||||
* Adds <link /> tags to <head />
|
* Logic:
|
||||||
* - Uses batch loading to reduce the number of requests
|
* - Variable fonts: Loaded once per slug (covers all weights).
|
||||||
* - Uses a queue to prevent too many requests at once
|
* - Static fonts: Loaded per slug + weight combination.
|
||||||
* - Purges unused fonts after a certain time
|
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
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>();
|
#usageTracker = new Map<string, number>();
|
||||||
// Stores: slug -> batchId
|
// Map: key -> batchId
|
||||||
#slugToBatch = new Map<string, string>();
|
#slugToBatch = new Map<string, string>();
|
||||||
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
// Map: batchId -> HTMLLinkElement
|
||||||
#batchElements = new Map<string, HTMLLinkElement>();
|
#batchElements = new Map<string, HTMLLinkElement>();
|
||||||
|
|
||||||
#queue = new Set<string>();
|
#queue = new Set<string>();
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#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;
|
#CHUNK_SIZE = 3;
|
||||||
|
|
||||||
|
// Reactive status map for UI feedback
|
||||||
statuses = new SvelteMap<string, FontStatus>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Start the "Janitor" loop
|
|
||||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the 'last seen' timestamp for fonts.
|
* Resolves a unique key for the font asset.
|
||||||
* Prevents them from being purged while they are on screen.
|
|
||||||
*/
|
*/
|
||||||
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 now = Date.now();
|
||||||
const toRegister: string[] = [];
|
const toRegister: string[] = [];
|
||||||
|
|
||||||
slugs.forEach(slug => {
|
configs.forEach(({ slug, weight, isVariable = false }) => {
|
||||||
this.#usageTracker.set(slug, now);
|
const key = this.#getFontKey(slug, weight, isVariable);
|
||||||
if (!this.#slugToBatch.has(slug)) {
|
|
||||||
toRegister.push(slug);
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
|
if (!this.#slugToBatch.has(key)) {
|
||||||
|
toRegister.push(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (toRegister.length > 0) this.registerFonts(toRegister);
|
if (toRegister.length > 0) this.registerFonts(toRegister);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerFonts(slugs: string[]) {
|
registerFonts(keys: string[]) {
|
||||||
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
|
||||||
if (newSlugs.length === 0) return;
|
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);
|
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFontStatus(slug: string) {
|
getFontStatus(slug: string, weight: number, isVariable: boolean) {
|
||||||
return this.statuses.get(slug);
|
return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
|
||||||
}
|
}
|
||||||
|
|
||||||
#processQueue() {
|
#processQueue() {
|
||||||
@@ -76,16 +93,23 @@ class AppliedFontsManager {
|
|||||||
this.#timeoutId = null;
|
this.#timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#createBatch(slugs: string[]) {
|
#createBatch(keys: string[]) {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
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`;
|
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
||||||
|
|
||||||
// Mark all as loading immediately
|
keys.forEach(key => this.statuses.set(key, 'loading'));
|
||||||
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
|
||||||
|
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
@@ -94,21 +118,24 @@ class AppliedFontsManager {
|
|||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
|
|
||||||
this.#batchElements.set(batchId, link);
|
this.#batchElements.set(batchId, link);
|
||||||
slugs.forEach(slug => {
|
|
||||||
this.#slugToBatch.set(slug, batchId);
|
|
||||||
|
|
||||||
// Use the Native Font Loading API
|
keys.forEach(key => {
|
||||||
// format: "font-size font-family"
|
this.#slugToBatch.set(key, batchId);
|
||||||
document.fonts.load(`1em "${slug}"`)
|
|
||||||
|
// 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 => {
|
.then(loadedFonts => {
|
||||||
if (loadedFonts.length > 0) {
|
this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
|
||||||
this.statuses.set(slug, 'loaded');
|
|
||||||
} else {
|
|
||||||
this.statuses.set(slug, 'error');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
this.statuses.set(slug, 'error');
|
this.statuses.set(key, 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -116,31 +143,30 @@ class AppliedFontsManager {
|
|||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToPotentialDelete = new Set<string>();
|
const batchesToPotentialDelete = new Set<string>();
|
||||||
const slugsToDelete: string[] = [];
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
// Identify expired slugs
|
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
||||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
|
||||||
if (now - lastUsed > this.#TTL) {
|
if (now - lastUsed > this.#TTL) {
|
||||||
const batchId = this.#slugToBatch.get(slug);
|
const batchId = this.#slugToBatch.get(key);
|
||||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
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 => {
|
batchesToPotentialDelete.forEach(batchId => {
|
||||||
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
const batchKeys = Array.from(this.#slugToBatch.entries())
|
||||||
.filter(([_, bId]) => bId === batchId)
|
.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) {
|
if (allExpired) {
|
||||||
this.#batchElements.get(batchId)?.remove();
|
this.#batchElements.get(batchId)?.remove();
|
||||||
this.#batchElements.delete(batchId);
|
this.#batchElements.delete(batchId);
|
||||||
batchSlugs.forEach(s => {
|
batchKeys.forEach(k => {
|
||||||
this.#slugToBatch.delete(s);
|
this.#slugToBatch.delete(k);
|
||||||
this.#usageTracker.delete(s);
|
this.#usageTracker.delete(k);
|
||||||
|
this.statuses.delete(k);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ interface Props {
|
|||||||
* Font id to load
|
* Font id to load
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Font weight
|
||||||
|
*/
|
||||||
|
weight?: number;
|
||||||
/**
|
/**
|
||||||
* Additional classes
|
* Additional classes
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +34,7 @@ interface Props {
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, id, className, children }: Props = $props();
|
let { name, id, weight = 400, className, children }: Props = $props();
|
||||||
let element: Element;
|
let element: Element;
|
||||||
|
|
||||||
// Track if the user has actually scrolled this into view
|
// Track if the user has actually scrolled this into view
|
||||||
@@ -40,7 +44,7 @@ $effect(() => {
|
|||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
hasEnteredViewport = true;
|
hasEnteredViewport = true;
|
||||||
appliedFontsManager.touch([id]);
|
appliedFontsManager.touch([{ slug: id, weight }]);
|
||||||
|
|
||||||
// Once it has entered, we can stop observing to save CPU
|
// Once it has entered, we can stop observing to save CPU
|
||||||
observer.unobserve(element);
|
observer.unobserve(element);
|
||||||
@@ -50,7 +54,7 @@ $effect(() => {
|
|||||||
return () => observer.disconnect();
|
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)
|
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
||||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appliedFontsManager } from '$entities/Font';
|
import { appliedFontsManager } from '$entities/Font';
|
||||||
|
import { controlManager } from '$features/SetupFont';
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
import { Input } from '$shared/shadcn/ui/input';
|
||||||
import { ComparisonSlider } from '$shared/ui';
|
import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
|
||||||
import { displayedFontsStore } from '../../model';
|
import { displayedFontsStore } from '../../model';
|
||||||
import PairSelector from '../PairSelector/PairSelector.svelte';
|
import PairSelector from '../PairSelector/PairSelector.svelte';
|
||||||
|
|
||||||
@@ -10,7 +11,9 @@ const [fontA, fontB] = $derived(displayedFontsStore.selectedPair);
|
|||||||
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
|
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
appliedFontsManager.touch(displayedFontsStore.fonts.map(font => font.id));
|
appliedFontsManager.touch(
|
||||||
|
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,7 +25,11 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if fontA && fontB}
|
{#if fontA && fontB}
|
||||||
<ComparisonSlider fontA={fontA} fontB={fontB} text={displayedText} />
|
<ComparisonSlider
|
||||||
|
fontA={fontA}
|
||||||
|
fontB={fontB}
|
||||||
|
bind:text={displayedText}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
|
import { controlManager } from '$features/SetupFont';
|
||||||
import { ContentEditable } from '$shared/ui';
|
import { ContentEditable } from '$shared/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,6 +32,8 @@ let {
|
|||||||
text = $bindable(),
|
text = $bindable(),
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const weight = $derived(controlManager.weight ?? 400);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -39,6 +42,7 @@ let {
|
|||||||
bg-white p-6 border border-slate-200
|
bg-white p-6 border border-slate-200
|
||||||
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
||||||
"
|
"
|
||||||
|
style:font-weight={weight}
|
||||||
>
|
>
|
||||||
<FontApplicator id={font.id} name={font.name}>
|
<FontApplicator id={font.id} name={font.name}>
|
||||||
<ContentEditable bind:text={text} {...restProps} />
|
<ContentEditable bind:text={text} {...restProps} />
|
||||||
|
|||||||
@@ -1,7 +1,49 @@
|
|||||||
import {
|
import {
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
type TypographyControl,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} 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.
|
* Creates a typography control manager that handles a collection of typography controls.
|
||||||
@@ -10,19 +52,5 @@ import {
|
|||||||
* @returns - Typography control manager instance.
|
* @returns - Typography control manager instance.
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
export function createTypographyControlManager(configs: ControlModel[]) {
|
||||||
const controls = $state(
|
return new TypographyControlManager(configs);
|
||||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
|
||||||
id,
|
|
||||||
increaseLabel,
|
|
||||||
decreaseLabel,
|
|
||||||
controlLabel,
|
|
||||||
instance: createTypographyControl(config),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
get controls() {
|
|
||||||
return controls;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function createCharacterComparison(
|
|||||||
text: () => string,
|
text: () => string,
|
||||||
fontA: () => { name: string; id: string },
|
fontA: () => { name: string; id: string },
|
||||||
fontB: () => { name: string; id: string },
|
fontB: () => { name: string; id: string },
|
||||||
|
weight: () => number,
|
||||||
|
size: () => number,
|
||||||
) {
|
) {
|
||||||
let lines = $state<LineData[]>([]);
|
let lines = $state<LineData[]>([]);
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state(0);
|
||||||
@@ -29,14 +31,16 @@ export function createCharacterComparison(
|
|||||||
* @param text - Text string to measure
|
* @param text - Text string to measure
|
||||||
* @param fontFamily - Font family name
|
* @param fontFamily - Font family name
|
||||||
* @param fontSize - Font size in pixels
|
* @param fontSize - Font size in pixels
|
||||||
|
* @param fontWeight - Font weight
|
||||||
*/
|
*/
|
||||||
function measureText(
|
function measureText(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
text: string,
|
text: string,
|
||||||
fontFamily: string,
|
fontFamily: string,
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
|
fontWeight: number,
|
||||||
): number {
|
): number {
|
||||||
ctx.font = `bold ${fontSize}px ${fontFamily}`;
|
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||||
return ctx.measureText(text).width;
|
return ctx.measureText(text).width;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +49,13 @@ export function createCharacterComparison(
|
|||||||
* Matches the Tailwind breakpoints used in the component.
|
* Matches the Tailwind breakpoints used in the component.
|
||||||
*/
|
*/
|
||||||
function getFontSize() {
|
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
|
return window.innerWidth >= 1024
|
||||||
? 112
|
? 112
|
||||||
: window.innerWidth >= 768
|
: window.innerWidth >= 768
|
||||||
@@ -62,6 +72,7 @@ export function createCharacterComparison(
|
|||||||
* @param container - The container element to measure width from
|
* @param container - The container element to measure width from
|
||||||
* @param measureCanvas - The canvas element used for text measurement
|
* @param measureCanvas - The canvas element used for text measurement
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function breakIntoLines(
|
function breakIntoLines(
|
||||||
container: HTMLElement | undefined,
|
container: HTMLElement | undefined,
|
||||||
measureCanvas: HTMLCanvasElement | undefined,
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
@@ -70,15 +81,15 @@ export function createCharacterComparison(
|
|||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
containerWidth = rect.width;
|
containerWidth = rect.width;
|
||||||
|
|
||||||
// Padding considerations - matches the container padding
|
// Padding considerations - matches the container padding
|
||||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
const availableWidth = rect.width - padding;
|
const availableWidth = rect.width - padding;
|
||||||
|
|
||||||
const ctx = measureCanvas.getContext('2d');
|
const ctx = measureCanvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const controlledFontSize = size();
|
||||||
const fontSize = getFontSize();
|
const fontSize = getFontSize();
|
||||||
|
const currentWeight = weight(); // Get current weight
|
||||||
const words = text().split(' ');
|
const words = text().split(' ');
|
||||||
const newLines: LineData[] = [];
|
const newLines: LineData[] = [];
|
||||||
let currentLineWords: string[] = [];
|
let currentLineWords: string[] = [];
|
||||||
@@ -86,9 +97,21 @@ export function createCharacterComparison(
|
|||||||
function pushLine(words: string[]) {
|
function pushLine(words: string[]) {
|
||||||
if (words.length === 0) return;
|
if (words.length === 0) return;
|
||||||
const lineText = words.join(' ');
|
const lineText = words.join(' ');
|
||||||
// Measure width to ensure we know exactly how wide this line renders
|
// Measure both fonts at the CURRENT weight
|
||||||
const widthA = measureText(ctx!, lineText, fontA().name, fontSize);
|
const widthA = measureText(
|
||||||
const widthB = measureText(ctx!, lineText, fontB().name, fontSize);
|
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);
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
newLines.push({ text: lineText, width: maxWidth });
|
newLines.push({ text: lineText, width: maxWidth });
|
||||||
}
|
}
|
||||||
@@ -97,10 +120,21 @@ export function createCharacterComparison(
|
|||||||
const testLine = currentLineWords.length > 0
|
const testLine = currentLineWords.length > 0
|
||||||
? currentLineWords.join(' ') + ' ' + word
|
? currentLineWords.join(' ') + ' ' + word
|
||||||
: word;
|
: word;
|
||||||
|
|
||||||
// Measure with both fonts and use the wider one to prevent layout shifts
|
// Measure with both fonts and use the wider one to prevent layout shifts
|
||||||
const widthA = measureText(ctx, testLine, fontA().name, fontSize);
|
const widthA = measureText(
|
||||||
const widthB = measureText(ctx, testLine, fontB().name, fontSize);
|
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);
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
|
|
||||||
if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||||
@@ -111,10 +145,7 @@ export function createCharacterComparison(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentLineWords.length > 0) {
|
if (currentLineWords.length > 0) pushLine(currentLineWords);
|
||||||
pushLine(currentLineWords);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = newLines;
|
lines = newLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel {
|
|||||||
/**
|
/**
|
||||||
* Area label for increase button
|
* Area label for increase button
|
||||||
*/
|
*/
|
||||||
increaseLabel: string;
|
increaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Area label for decrease button
|
* Area label for decrease button
|
||||||
*/
|
*/
|
||||||
decreaseLabel: string;
|
decreaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Control area label
|
* Control area label
|
||||||
*/
|
*/
|
||||||
controlLabel: string;
|
controlLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTypographyControl<T extends ControlDataModel>(
|
export function createTypographyControl<T extends ControlDataModel>(
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
|
type LineData,
|
||||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
type EntityStore,
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
|
type LineData,
|
||||||
type Property,
|
type Property,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
|
|||||||
@@ -8,9 +8,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TypographyControl } from '$shared/lib';
|
import type { TypographyControl } from '$shared/lib';
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
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 { 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 { Slider } from '$shared/shadcn/ui/slider';
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
@@ -67,30 +71,32 @@ const handleSliderChange = (newValue: number) => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ButtonGroup.Root>
|
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
class="hover:bg-white/50 bg-white/20 border-none"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={decreaseLabel}
|
aria-label={decreaseLabel}
|
||||||
onclick={control.decrease}
|
onclick={control.decrease}
|
||||||
disabled={control.isAtMin}
|
disabled={control.isAtMin}
|
||||||
>
|
>
|
||||||
<MinusIcon />
|
<MinusIcon class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Popover.Root>
|
<PopoverRoot>
|
||||||
<Popover.Trigger>
|
<PopoverTrigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
class="hover:bg-white/50 bg-white/20 border-none"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={controlLabel}
|
aria-label={controlLabel}
|
||||||
>
|
>
|
||||||
{control.value}
|
{control.value}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</PopoverTrigger>
|
||||||
<Popover.Content class="w-auto p-4">
|
<PopoverContent class="w-auto p-4">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<Slider
|
<Slider
|
||||||
min={control.min}
|
min={control.min}
|
||||||
@@ -110,15 +116,16 @@ const handleSliderChange = (newValue: number) => {
|
|||||||
class="w-16 text-center"
|
class="w-16 text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Content>
|
</PopoverContent>
|
||||||
</Popover.Root>
|
</PopoverRoot>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
class="hover:bg-white/50 bg-white/20 border-none"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={increaseLabel}
|
aria-label={increaseLabel}
|
||||||
onclick={control.increase}
|
onclick={control.increase}
|
||||||
disabled={control.isAtMax}
|
disabled={control.isAtMax}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon class="size-4" />
|
||||||
</Button>
|
</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 CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||||
import ComboControl from './ComboControl/ComboControl.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 ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||||
@@ -14,7 +14,7 @@ import VirtualList from './VirtualList/VirtualList.svelte';
|
|||||||
export {
|
export {
|
||||||
CheckboxFilter,
|
CheckboxFilter,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
ComparisonSlider,
|
ComboControlV2,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
VirtualList,
|
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