Compare commits
6 Commits
fb190f82b9
...
c2542026a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2542026a4 | ||
|
|
3f8fd357d8 | ||
|
|
1bd2a4f2f8 | ||
|
|
746a377038 | ||
|
|
1b76284237 | ||
|
|
b5ad3249ae |
@@ -12,7 +12,15 @@ export class FontshareStore extends BaseFontStore<FontshareParams> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getQueryKey(params: FontshareParams) {
|
protected getQueryKey(params: FontshareParams) {
|
||||||
return ['fontshare', params] as const;
|
// Normalize params to treat empty arrays/strings as undefined
|
||||||
|
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||||
|
if (value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
return { ...acc, [key]: value };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return ['fontshare', normalized] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
|
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
- Renders a virtualized list of fonts
|
- Renders a virtualized list of fonts
|
||||||
- Handles font registration with the manager
|
- Handles font registration with the manager
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T extends { id: string }">
|
<script lang="ts" generics="T extends ({ id: string } | [{ id: string }, { id: string }])">
|
||||||
import { VirtualList } from '$shared/ui';
|
import { VirtualList } from '$shared/ui';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
import { appliedFontsManager } from '../../model';
|
import { appliedFontsManager } from '../../model';
|
||||||
@@ -16,7 +16,12 @@ let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
|
|||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||||
// Auto-register fonts with the manager
|
// Auto-register fonts with the manager
|
||||||
const slugs = visibleItems.map(item => item.id);
|
const slugs = visibleItems.map(item => {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
return item.map(font => font.id);
|
||||||
|
}
|
||||||
|
return item.id;
|
||||||
|
}).flat();
|
||||||
appliedFontsManager.registerFonts(slugs);
|
appliedFontsManager.registerFonts(slugs);
|
||||||
|
|
||||||
// Forward the call to any external listener
|
// Forward the call to any external listener
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { selectedFontsStore } from '$entities/Font';
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
selectedFontsStore,
|
||||||
|
} from '$entities/Font';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store for displayed font samples
|
* Store for displayed font samples
|
||||||
@@ -12,10 +15,35 @@ export class DisplayedFontsStore {
|
|||||||
return selectedFontsStore.all;
|
return selectedFontsStore.all;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#fontPairs = $derived.by(() => {
|
||||||
|
const fonts = this.#displayedFonts;
|
||||||
|
const pairs = fonts.flatMap((v1, i) =>
|
||||||
|
fonts.slice(i + 1).map<[UnifiedFont, UnifiedFont]>(v2 => [v1, v2])
|
||||||
|
);
|
||||||
|
if (pairs.length && this.isSelectedPairEmpty()) {
|
||||||
|
this.selectedPair = pairs[0];
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
});
|
||||||
|
|
||||||
|
#selectedPair = $state<Partial<[UnifiedFont, UnifiedFont]>>([]);
|
||||||
|
|
||||||
get fonts() {
|
get fonts() {
|
||||||
return this.#displayedFonts;
|
return this.#displayedFonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get pairs() {
|
||||||
|
return this.#fontPairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedPair() {
|
||||||
|
return this.#selectedPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
set selectedPair(pair: Partial<[UnifiedFont, UnifiedFont]>) {
|
||||||
|
this.#selectedPair = pair;
|
||||||
|
}
|
||||||
|
|
||||||
get text() {
|
get text() {
|
||||||
return this.#sampleText;
|
return this.#sampleText;
|
||||||
}
|
}
|
||||||
@@ -23,6 +51,11 @@ export class DisplayedFontsStore {
|
|||||||
set text(text: string) {
|
set text(text: string) {
|
||||||
this.#sampleText = text;
|
this.#sampleText = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSelectedPairEmpty(): boolean {
|
||||||
|
const [font1, font2] = this.#selectedPair;
|
||||||
|
return !font1 || !font2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const displayedFontsStore = new DisplayedFontsStore();
|
export const displayedFontsStore = new DisplayedFontsStore();
|
||||||
|
|||||||
28
src/features/DisplayFont/ui/FontComparer/FontComparer.svelte
Normal file
28
src/features/DisplayFont/ui/FontComparer/FontComparer.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { appliedFontsManager } from '$entities/Font';
|
||||||
|
import { Input } from '$shared/shadcn/ui/input';
|
||||||
|
import { ComparisonSlider } from '$shared/ui';
|
||||||
|
import { displayedFontsStore } from '../../model';
|
||||||
|
import PairSelector from '../PairSelector/PairSelector.svelte';
|
||||||
|
|
||||||
|
let displayedText = $state('The quick brown fox jumps over the lazy dog...');
|
||||||
|
const [fontA, fontB] = $derived(displayedFontsStore.selectedPair);
|
||||||
|
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
appliedFontsManager.touch(displayedFontsStore.fonts.map(font => font.id));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasAnyPairs}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-row gap-4">
|
||||||
|
<Input bind:value={displayedText} />
|
||||||
|
<PairSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if fontA && fontB}
|
||||||
|
<ComparisonSlider fontA={fontA} fontB={fontB} text={displayedText} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
Component: FontDisplay
|
Component: FontDisplay
|
||||||
Displays a grid of FontSampler components for each displayed font.
|
Displays a grid of FontSampler components for each displayed font.
|
||||||
-->
|
-->
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { displayedFontsStore } from '../../model';
|
import { displayedFontsStore } from '../../model';
|
||||||
|
import FontComparer from '../FontComparer/FontComparer.svelte';
|
||||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
import FontSampler from '../FontSampler/FontSampler.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<FontComparer />
|
||||||
|
|
||||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
||||||
{#each displayedFontsStore.fonts as font (font.id)}
|
{#each displayedFontsStore.fonts as font (font.id)}
|
||||||
<FontSampler font={font} bind:text={displayedFontsStore.text} />
|
<FontSampler font={font} bind:text={displayedFontsStore.text} />
|
||||||
|
|||||||
29
src/features/DisplayFont/ui/FontPair/FontPair.svelte
Normal file
29
src/features/DisplayFont/ui/FontPair/FontPair.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FontApplicator,
|
||||||
|
type UnifiedFont,
|
||||||
|
} from '$entities/Font';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pair: [UnifiedFont, UnifiedFont];
|
||||||
|
selectedPair: Partial<[UnifiedFont, UnifiedFont]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pair, selectedPair = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const [font1, font2] = $derived(pair);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
selectedPair = pair;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-row justify-between w-full" onclick={handleClick}>
|
||||||
|
<FontApplicator id={font1.id} name={font1.name}>
|
||||||
|
{font1.name}
|
||||||
|
</FontApplicator>
|
||||||
|
vs
|
||||||
|
<FontApplicator id={font2.id} name={font2.name}>
|
||||||
|
{font2.name}
|
||||||
|
</FontApplicator>
|
||||||
|
</div>
|
||||||
34
src/features/DisplayFont/ui/PairSelector/PairSelector.svelte
Normal file
34
src/features/DisplayFont/ui/PairSelector/PairSelector.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FontVirtualList } from '$entities/Font';
|
||||||
|
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||||
|
import {
|
||||||
|
Content as PopoverContent,
|
||||||
|
Root as PopoverRoot,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
} from '$shared/shadcn/ui/popover';
|
||||||
|
import { displayedFontsStore } from '../../model';
|
||||||
|
import FontPair from '../FontPair/FontPair.svelte';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
const triggerContent = $derived.by(() => {
|
||||||
|
const [beforeFont, afterFont] = displayedFontsStore.selectedPair ?? [];
|
||||||
|
if (!beforeFont || !afterFont) return 'Select a pair';
|
||||||
|
return `${beforeFont.name} vs ${afterFont.name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerDisabled = $derived(displayedFontsStore.pairs.length === 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverRoot bind:open>
|
||||||
|
<PopoverTrigger class={buttonVariants({ variant: 'outline' })} disabled={triggerDisabled}>
|
||||||
|
{triggerContent}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<FontVirtualList items={displayedFontsStore.pairs}>
|
||||||
|
{#snippet children({ item: pair })}
|
||||||
|
<FontPair {pair} bind:selectedPair={displayedFontsStore.selectedPair} />
|
||||||
|
{/snippet}
|
||||||
|
</FontVirtualList>
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverRoot>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Interface representing a line of text with its measured width.
|
||||||
|
*/
|
||||||
|
export interface LineData {
|
||||||
|
text: string;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a helper for splitting text into lines and calculating character proximity.
|
||||||
|
* This is used by the ComparisonSlider (TestTen) to render morphing text.
|
||||||
|
*
|
||||||
|
* @param text - The text to split and measure
|
||||||
|
* @param fontA - The first font definition
|
||||||
|
* @param fontB - The second font definition
|
||||||
|
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
||||||
|
*/
|
||||||
|
export function createCharacterComparison(
|
||||||
|
text: () => string,
|
||||||
|
fontA: () => { name: string; id: string },
|
||||||
|
fontB: () => { name: string; id: string },
|
||||||
|
) {
|
||||||
|
let lines = $state<LineData[]>([]);
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures text width using a canvas context.
|
||||||
|
* @param ctx - Canvas rendering context
|
||||||
|
* @param text - Text string to measure
|
||||||
|
* @param fontFamily - Font family name
|
||||||
|
* @param fontSize - Font size in pixels
|
||||||
|
*/
|
||||||
|
function measureText(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
fontFamily: string,
|
||||||
|
fontSize: number,
|
||||||
|
): number {
|
||||||
|
ctx.font = `bold ${fontSize}px ${fontFamily}`;
|
||||||
|
return ctx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate font size based on window width.
|
||||||
|
* Matches the Tailwind breakpoints used in the component.
|
||||||
|
*/
|
||||||
|
function getFontSize() {
|
||||||
|
if (typeof window === 'undefined') return 64;
|
||||||
|
return window.innerWidth >= 1024
|
||||||
|
? 112
|
||||||
|
: window.innerWidth >= 768
|
||||||
|
? 96
|
||||||
|
: window.innerWidth >= 640
|
||||||
|
? 80
|
||||||
|
: 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks the text into lines based on the container width and measure canvas.
|
||||||
|
* Populates the `lines` state.
|
||||||
|
*
|
||||||
|
* @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,
|
||||||
|
) {
|
||||||
|
if (!container || !measureCanvas) return;
|
||||||
|
|
||||||
|
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 fontSize = getFontSize();
|
||||||
|
const words = text().split(' ');
|
||||||
|
const newLines: LineData[] = [];
|
||||||
|
let currentLineWords: string[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
|
newLines.push({ text: lineText, width: maxWidth });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
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 maxWidth = Math.max(widthA, widthB);
|
||||||
|
|
||||||
|
if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
currentLineWords = [word];
|
||||||
|
} else {
|
||||||
|
currentLineWords.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = newLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* precise calculation of character state based on global slider position.
|
||||||
|
*
|
||||||
|
* @param lineIndex - Index of the line
|
||||||
|
* @param charIndex - Index of the character in the line
|
||||||
|
* @param lineData - The line data object
|
||||||
|
* @param sliderPos - Current slider position (0-100)
|
||||||
|
* @returns Object containing proximity (0-1) and isPast (boolean)
|
||||||
|
*/
|
||||||
|
function getCharState(
|
||||||
|
lineIndex: number,
|
||||||
|
charIndex: number,
|
||||||
|
lineData: LineData,
|
||||||
|
sliderPos: number,
|
||||||
|
) {
|
||||||
|
if (!containerWidth) return { proximity: 0, isPast: false };
|
||||||
|
|
||||||
|
// Calculate the pixel position of the character relative to the CONTAINER
|
||||||
|
// 1. Find the left edge of the centered line
|
||||||
|
const lineStartOffset = (containerWidth - lineData.width) / 2;
|
||||||
|
|
||||||
|
// 2. Find the character's center relative to the line
|
||||||
|
const charRelativePercent = (charIndex + 0.5) / lineData.text.length;
|
||||||
|
const charPixelPos = lineStartOffset + (charRelativePercent * lineData.width);
|
||||||
|
|
||||||
|
// 3. Convert back to global percentage (0-100)
|
||||||
|
const charGlobalPercent = (charPixelPos / containerWidth) * 100;
|
||||||
|
|
||||||
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
|
|
||||||
|
// Proximity range: +/- 15% around the slider
|
||||||
|
const range = 15;
|
||||||
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
|
|
||||||
|
const isPast = sliderPos > charGlobalPercent;
|
||||||
|
|
||||||
|
return { proximity, isPast };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get lines() {
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
get containerWidth() {
|
||||||
|
return containerWidth;
|
||||||
|
},
|
||||||
|
breakIntoLines,
|
||||||
|
getCharState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,3 +26,7 @@ export {
|
|||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createCharacterComparison,
|
||||||
|
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
createCharacterComparison,
|
||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
|
|||||||
182
src/shared/ui/ComparisonSlider/ComparisonSlider.svelte
Normal file
182
src/shared/ui/ComparisonSlider/ComparisonSlider.svelte
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<!--
|
||||||
|
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>
|
||||||
37
src/shared/ui/ComparisonSlider/components/Labels.svelte
Normal file
37
src/shared/ui/ComparisonSlider/components/Labels.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fontA: { name: string; id: string };
|
||||||
|
fontB: { name: string; id: string };
|
||||||
|
sliderPos: number;
|
||||||
|
}
|
||||||
|
let { fontA, fontB, sliderPos }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Bottom Labels -->
|
||||||
|
<div class="absolute bottom-6 inset-x-8 sm:inset-x-12 flex justify-between items-center pointer-events-none z-20">
|
||||||
|
<!-- Left Label (Font A) -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-1 transition-opacity duration-300"
|
||||||
|
style:opacity={sliderPos < 10 ? 0 : 1}
|
||||||
|
>
|
||||||
|
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400"
|
||||||
|
>Baseline</span>
|
||||||
|
<span class="text-xs sm:text-sm font-bold text-indigo-600">
|
||||||
|
{fontB.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Label (Font B) -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300"
|
||||||
|
style:opacity={sliderPos > 90 ? 0 : 1}
|
||||||
|
>
|
||||||
|
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400"
|
||||||
|
>Comparison</span>
|
||||||
|
<span class="text-xs sm:text-sm font-bold text-slate-900">
|
||||||
|
{fontA.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
26
src/shared/ui/ComparisonSlider/components/SliderLine.svelte
Normal file
26
src/shared/ui/ComparisonSlider/components/SliderLine.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<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,6 +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 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';
|
||||||
@@ -13,6 +14,7 @@ import VirtualList from './VirtualList/VirtualList.svelte';
|
|||||||
export {
|
export {
|
||||||
CheckboxFilter,
|
CheckboxFilter,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
|
ComparisonSlider,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
|
|||||||
Reference in New Issue
Block a user