feature/project-redesign #28
@@ -1,2 +0,0 @@
|
|||||||
export * from './model';
|
|
||||||
export { ComparisonSlider } from './ui';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { comparisonStore } from './stores/comparisonStore.svelte';
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import {
|
|
||||||
type UnifiedFont,
|
|
||||||
fetchFontsByIds,
|
|
||||||
unifiedFontStore,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import {
|
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
|
||||||
createTypographyControlManager,
|
|
||||||
} from '$features/SetupFont';
|
|
||||||
import { createPersistentStore } from '$shared/lib';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage schema for comparison state
|
|
||||||
*/
|
|
||||||
interface ComparisonState {
|
|
||||||
fontAId: string | null;
|
|
||||||
fontBId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistent storage for selected comparison fonts
|
|
||||||
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
|
||||||
fontAId: null,
|
|
||||||
fontBId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store for managing font comparison state
|
|
||||||
* - Persists selection to localStorage
|
|
||||||
* - Handles font fetching on initialization
|
|
||||||
* - Manages sample text
|
|
||||||
*/
|
|
||||||
class ComparisonStore {
|
|
||||||
#fontA = $state<UnifiedFont | undefined>();
|
|
||||||
#fontB = $state<UnifiedFont | undefined>();
|
|
||||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
|
||||||
#isRestoring = $state(true);
|
|
||||||
#fontsReady = $state(false);
|
|
||||||
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.restoreFromStorage();
|
|
||||||
|
|
||||||
// Reactively set defaults if we aren't restoring and have no selection
|
|
||||||
$effect.root(() => {
|
|
||||||
$effect(() => {
|
|
||||||
// Wait until we are done checking storage
|
|
||||||
if (this.#isRestoring) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we already have a selection, do nothing
|
|
||||||
if (this.#fontA && this.#fontB) {
|
|
||||||
this.#checkFontsLoaded();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if fonts are available to set as defaults
|
|
||||||
const fonts = unifiedFontStore.fonts;
|
|
||||||
if (fonts.length >= 2) {
|
|
||||||
// Only set if we really have nothing (fallback)
|
|
||||||
if (!this.#fontA) this.#fontA = fonts[0];
|
|
||||||
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
|
|
||||||
|
|
||||||
// Sync defaults to storage so they persist if the user leaves
|
|
||||||
this.updateStorage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if fonts are actually loaded in the browser at current weight.
|
|
||||||
* Uses CSS Font Loading API to prevent FOUT.
|
|
||||||
*/
|
|
||||||
async #checkFontsLoaded() {
|
|
||||||
if (!('fonts' in document)) {
|
|
||||||
this.#fontsReady = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const weight = this.#typography.weight;
|
|
||||||
const size = this.#typography.renderedSize;
|
|
||||||
const fontAName = this.#fontA?.name;
|
|
||||||
const fontBName = this.#fontB?.name;
|
|
||||||
|
|
||||||
if (!fontAName || !fontBName) return;
|
|
||||||
|
|
||||||
const fontAString = `${weight} ${size}px "${fontAName}"`;
|
|
||||||
const fontBString = `${weight} ${size}px "${fontBName}"`;
|
|
||||||
|
|
||||||
// Check if already loaded to avoid UI flash
|
|
||||||
const isALoaded = document.fonts.check(fontAString);
|
|
||||||
const isBLoaded = document.fonts.check(fontBString);
|
|
||||||
|
|
||||||
if (isALoaded && isBLoaded) {
|
|
||||||
this.#fontsReady = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#fontsReady = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: Load fonts into memory
|
|
||||||
await Promise.all([
|
|
||||||
document.fonts.load(fontAString),
|
|
||||||
document.fonts.load(fontBString),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Step 2: Wait for browser to be ready to render
|
|
||||||
await document.fonts.ready;
|
|
||||||
|
|
||||||
// Step 3: Force a layout/paint cycle (critical!)
|
|
||||||
await new Promise(resolve => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(resolve); // Double rAF ensures paint completes
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#fontsReady = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[ComparisonStore] Font loading failed:', error);
|
|
||||||
setTimeout(() => this.#fontsReady = true, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Restore state from persistent storage
|
|
||||||
*/
|
|
||||||
async restoreFromStorage() {
|
|
||||||
this.#isRestoring = true;
|
|
||||||
const { fontAId, fontBId } = storage.value;
|
|
||||||
|
|
||||||
if (fontAId && fontBId) {
|
|
||||||
try {
|
|
||||||
// Batch fetch the saved fonts
|
|
||||||
const fonts = await fetchFontsByIds([fontAId, fontBId]);
|
|
||||||
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
|
|
||||||
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
|
|
||||||
|
|
||||||
if (loadedFontA && loadedFontB) {
|
|
||||||
this.#fontA = loadedFontA;
|
|
||||||
this.#fontB = loadedFontB;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[ComparisonStore] Failed to restore fonts:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark restoration as complete (whether success or fail)
|
|
||||||
this.#isRestoring = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update storage with current state
|
|
||||||
*/
|
|
||||||
private updateStorage() {
|
|
||||||
// Don't save if we are currently restoring (avoid race)
|
|
||||||
if (this.#isRestoring) return;
|
|
||||||
|
|
||||||
storage.value = {
|
|
||||||
fontAId: this.#fontA?.id ?? null,
|
|
||||||
fontBId: this.#fontB?.id ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Getters & Setters ---
|
|
||||||
get typography() {
|
|
||||||
return this.#typography;
|
|
||||||
}
|
|
||||||
|
|
||||||
get fontA() {
|
|
||||||
return this.#fontA;
|
|
||||||
}
|
|
||||||
|
|
||||||
set fontA(font: UnifiedFont | undefined) {
|
|
||||||
this.#fontA = font;
|
|
||||||
this.updateStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
get fontB() {
|
|
||||||
return this.#fontB;
|
|
||||||
}
|
|
||||||
|
|
||||||
set fontB(font: UnifiedFont | undefined) {
|
|
||||||
this.#fontB = font;
|
|
||||||
this.updateStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
get text() {
|
|
||||||
return this.#sampleText;
|
|
||||||
}
|
|
||||||
|
|
||||||
set text(value: string) {
|
|
||||||
this.#sampleText = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if both fonts are selected
|
|
||||||
*/
|
|
||||||
get isReady() {
|
|
||||||
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isLoading() {
|
|
||||||
return this.#isRestoring || !this.#fontsReady;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Public initializer (optional, as constructor starts it)
|
|
||||||
* Kept for compatibility if manual re-init is needed
|
|
||||||
*/
|
|
||||||
initialize() {
|
|
||||||
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
|
|
||||||
this.restoreFromStorage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAll() {
|
|
||||||
this.#fontA = undefined;
|
|
||||||
this.#fontB = undefined;
|
|
||||||
storage.clear();
|
|
||||||
this.#typography.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const comparisonStore = new ComparisonStore();
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<script module>
|
|
||||||
import Providers from '$shared/lib/storybook/Providers.svelte';
|
|
||||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
|
||||||
import ComparisonSlider from './ComparisonSlider.svelte';
|
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
|
||||||
title: 'Widgets/ComparisonSlider',
|
|
||||||
component: ComparisonSlider,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'A multiline text comparison slider that morphs between two fonts. Features character-level morphing, responsive layout, and performance optimization using offscreen canvas. Switch between slider mode (interactive text) and settings mode (controls panel).',
|
|
||||||
},
|
|
||||||
story: { inline: false },
|
|
||||||
},
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// This component uses internal stores, so no direct props to document
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
|
||||||
|
|
||||||
// Mock fonts for testing - using web-safe fonts that are always available
|
|
||||||
const mockArial: UnifiedFont = {
|
|
||||||
id: 'arial',
|
|
||||||
name: 'Arial',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700'],
|
|
||||||
styles: {
|
|
||||||
regular: '',
|
|
||||||
bold: '',
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
popularity: 1,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockGeorgia: UnifiedFont = {
|
|
||||||
id: 'georgia',
|
|
||||||
name: 'Georgia',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700'],
|
|
||||||
styles: {
|
|
||||||
regular: '',
|
|
||||||
bold: '',
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
popularity: 2,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockVerdana: UnifiedFont = {
|
|
||||||
id: 'verdana',
|
|
||||||
name: 'Verdana',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'sans-serif',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700'],
|
|
||||||
styles: {
|
|
||||||
regular: '',
|
|
||||||
bold: '',
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
popularity: 3,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCourier: UnifiedFont = {
|
|
||||||
id: 'courier-new',
|
|
||||||
name: 'Courier New',
|
|
||||||
provider: 'google',
|
|
||||||
category: 'monospace',
|
|
||||||
subsets: ['latin'],
|
|
||||||
variants: ['400', '700'],
|
|
||||||
styles: {
|
|
||||||
regular: '',
|
|
||||||
bold: '',
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: '1.0',
|
|
||||||
popularity: 10,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Story name="Default">
|
|
||||||
{@const _ = (comparisonStore.fontA = mockArial, comparisonStore.fontB = mockGeorgia)}
|
|
||||||
<Providers>
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-8">
|
|
||||||
<div class="w-full max-w-5xl">
|
|
||||||
<ComparisonSlider />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="Loading State">
|
|
||||||
{@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)}
|
|
||||||
<Providers>
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-8">
|
|
||||||
<div class="w-full max-w-5xl">
|
|
||||||
<ComparisonSlider />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
</Story>
|
|
||||||
@@ -1,278 +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.
|
|
||||||
|
|
||||||
Modes:
|
|
||||||
- Slider mode: Text centered in 1st plan, controls hidden
|
|
||||||
- Settings mode: Text moves to left (2nd plan), controls appear on right (1st plan)
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type CharacterComparison,
|
|
||||||
type LineData,
|
|
||||||
type ResponsiveManager,
|
|
||||||
createCharacterComparison,
|
|
||||||
createPerspectiveManager,
|
|
||||||
} from '$shared/lib';
|
|
||||||
import {
|
|
||||||
Loader,
|
|
||||||
PerspectivePlan,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import { Spring } from 'svelte/motion';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
|
||||||
import Controls from './components/Controls.svelte';
|
|
||||||
import SliderLine from './components/SliderLine.svelte';
|
|
||||||
|
|
||||||
// Pair of fonts to compare
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
|
||||||
|
|
||||||
const isLoading = $derived(
|
|
||||||
comparisonStore.isLoading || !comparisonStore.isReady,
|
|
||||||
);
|
|
||||||
|
|
||||||
let container = $state<HTMLElement>();
|
|
||||||
let measureCanvas = $state<HTMLCanvasElement>();
|
|
||||||
let isDragging = $state(false);
|
|
||||||
const typography = $derived(comparisonStore.typography);
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
|
||||||
* Manages line breaking and character state based on fonts and container dimensions.
|
|
||||||
*/
|
|
||||||
const charComparison: CharacterComparison = createCharacterComparison(
|
|
||||||
() => comparisonStore.text,
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => typography.weight,
|
|
||||||
() => typography.renderedSize,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perspective manager for back/front state toggling:
|
|
||||||
* - Front (slider mode): Text fully visible, interactive
|
|
||||||
* - Back (settings mode): Text blurred, scaled down, shifted left, controls visible
|
|
||||||
*
|
|
||||||
* Uses simple boolean flag for smooth transitions between states.
|
|
||||||
*/
|
|
||||||
const perspective = createPerspectiveManager({
|
|
||||||
parallaxIntensity: 0, // Disabled to not interfere with slider
|
|
||||||
horizontalOffset: 0, // Text shifts left when in back position
|
|
||||||
scaleStep: 0.5,
|
|
||||||
blurStep: 2,
|
|
||||||
depthStep: 100,
|
|
||||||
opacityStep: 0.3,
|
|
||||||
});
|
|
||||||
|
|
||||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
|
||||||
|
|
||||||
/** 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePerspective() {
|
|
||||||
perspective.toggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the multiplier for slider font size based on the current responsive state
|
|
||||||
*/
|
|
||||||
$effect(() => {
|
|
||||||
if (!responsive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (true) {
|
|
||||||
case responsive.isMobile:
|
|
||||||
typography.multiplier = 0.5;
|
|
||||||
break;
|
|
||||||
case responsive.isTablet:
|
|
||||||
typography.multiplier = 0.75;
|
|
||||||
break;
|
|
||||||
case responsive.isDesktop:
|
|
||||||
typography.multiplier = 1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
typography.multiplier = 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$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 = comparisonStore.text;
|
|
||||||
const _weight = typography.weight;
|
|
||||||
const _size = typography.renderedSize;
|
|
||||||
const _height = typography.height;
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isInSettingsMode = $derived(perspective.isBack);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet renderLine(line: LineData, index: number)}
|
|
||||||
{@const pos = sliderPos}
|
|
||||||
{@const element = lineElements[index]}
|
|
||||||
<div
|
|
||||||
bind:this={lineElements[index]}
|
|
||||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
|
||||||
style:height={`${typography.height}em`}
|
|
||||||
style:line-height={`${typography.height}em`}
|
|
||||||
>
|
|
||||||
{#each line.text.split('') as char, index}
|
|
||||||
{@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
|
|
||||||
<!--
|
|
||||||
Single Character Span
|
|
||||||
- Font Family switches based on `isPast`
|
|
||||||
- Transitions/Transforms provide the "morph" feel
|
|
||||||
-->
|
|
||||||
{#if fontA && fontB}
|
|
||||||
<CharacterSlot {char} {proximity} {isPast} />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<!-- Hidden canvas used for text measurement by the helper -->
|
|
||||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
|
||||||
|
|
||||||
<!-- Main container with perspective and fixed height -->
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
relative w-full flex justify-center items-center
|
|
||||||
perspective-distant perspective-origin-center transform-3d
|
|
||||||
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
|
||||||
min-h-72 sm:min-h-96 lg:min-h-128
|
|
||||||
backdrop-blur-lg bg-linear-to-br from-gray-200/40 via-white/80 to-gray-100/60
|
|
||||||
border border-border-muted
|
|
||||||
shadow-[inset_2px_0_8px_rgba(0,0,0,0.05)]
|
|
||||||
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
|
||||||
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
|
||||||
before:-z-10 before:blur-sm
|
|
||||||
overflow-hidden
|
|
||||||
[perspective:1500px] perspective-origin-center transform-3d
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{#if isLoading}
|
|
||||||
<div out:fade={{ duration: 300 }}>
|
|
||||||
<Loader size={24} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Text Plan -->
|
|
||||||
<PerspectivePlan
|
|
||||||
manager={perspective}
|
|
||||||
class="absolute inset-0 flex justify-center origin-right w-full h-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
role="slider"
|
|
||||||
tabindex="0"
|
|
||||||
aria-valuenow={Math.round(sliderPos)}
|
|
||||||
aria-label="Font comparison slider"
|
|
||||||
onpointerdown={startDragging}
|
|
||||||
class="
|
|
||||||
relative w-full h-full flex justify-center
|
|
||||||
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
|
|
||||||
select-none touch-none cursor-ew-resize
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
relative flex flex-col items-center gap-3 sm:gap-4
|
|
||||||
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
|
|
||||||
z-10 pointer-events-none text-center
|
|
||||||
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
|
||||||
my-auto
|
|
||||||
"
|
|
||||||
in:fade={{ duration: 300, delay: 300 }}
|
|
||||||
out:fade={{ duration: 300 }}
|
|
||||||
>
|
|
||||||
{#each charComparison.lines as line, lineIndex}
|
|
||||||
<div
|
|
||||||
class="relative w-full whitespace-nowrap"
|
|
||||||
style:height={`${typography.height}em`}
|
|
||||||
style:display="flex"
|
|
||||||
style:align-items="center"
|
|
||||||
style:justify-content="center"
|
|
||||||
>
|
|
||||||
{@render renderLine(line, lineIndex)}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Slider Line - visible in slider mode -->
|
|
||||||
{#if !isInSettingsMode}
|
|
||||||
<SliderLine {sliderPos} {isDragging} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</PerspectivePlan>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
class="absolute inset-y-0 left-0 transition-all duration-150"
|
|
||||||
handleToggle={togglePerspective}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<!--
|
|
||||||
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';
|
|
||||||
import { comparisonStore } from '../../../model';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { char, proximity, isPast }: Props = $props();
|
|
||||||
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
|
||||||
const typography = $derived(comparisonStore.typography);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if fontA && fontB}
|
|
||||||
<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 ? fontB.name : fontA.name}
|
|
||||||
style:font-weight={typography.weight}
|
|
||||||
style:font-size={`${typography.renderedSize}px`}
|
|
||||||
style:transform="
|
|
||||||
scale({1 + proximity * 0.3}) 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>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<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,131 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: Controls
|
|
||||||
Uses SidebarMenu to show ComparisonSlider's controls:
|
|
||||||
- List of fonts to pick
|
|
||||||
- Input to change text
|
|
||||||
- Sliders for font-weight, font-width, line-height
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { appliedFontsManager } from '$entities/Font';
|
|
||||||
import { getFontUrl } from '$entities/Font/lib';
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { SidebarMenu } from '$shared/ui';
|
|
||||||
import { Label } from '$shared/ui';
|
|
||||||
import Drawer from '$shared/ui/Drawer/Drawer.svelte';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import FontList from './FontList.svelte';
|
|
||||||
import ToggleMenuButton from './ToggleMenuButton.svelte';
|
|
||||||
import TypographyControls from './TypographyControls.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Additional class
|
|
||||||
*/
|
|
||||||
class?: string;
|
|
||||||
/**
|
|
||||||
* Handler to trigger when menu opens/closes
|
|
||||||
*/
|
|
||||||
handleToggle?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { class: className, handleToggle }: Props = $props();
|
|
||||||
|
|
||||||
let visible = $state(false);
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
|
||||||
const typography = $derived(comparisonStore.typography);
|
|
||||||
let menuWrapper = $state<HTMLElement | null>(null);
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!fontA || !fontB) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const weight = typography.weight;
|
|
||||||
const fontAUrl = getFontUrl(fontA, weight);
|
|
||||||
const fontBUrl = getFontUrl(fontB, weight);
|
|
||||||
|
|
||||||
if (!fontAUrl || !fontBUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontAConfig = {
|
|
||||||
id: fontA.id,
|
|
||||||
name: fontA.name,
|
|
||||||
url: fontAUrl,
|
|
||||||
weight: weight,
|
|
||||||
};
|
|
||||||
const fontBConfig = {
|
|
||||||
id: fontB.id,
|
|
||||||
name: fontB.name,
|
|
||||||
url: fontBUrl,
|
|
||||||
weight: weight,
|
|
||||||
};
|
|
||||||
|
|
||||||
appliedFontsManager.touch([fontAConfig, fontBConfig]);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if responsive.isMobile}
|
|
||||||
<Drawer>
|
|
||||||
{#snippet trigger({ onClick })}
|
|
||||||
<div class={cn('absolute bottom-0.5 left-1/2 -translate-x-1/2 z-50')}>
|
|
||||||
<ToggleMenuButton bind:isActive={visible} {onClick} />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet content({ className })}
|
|
||||||
<div class="w-full pt-4 grid grid-cols-[1fr_min-content_1fr] gap-2 items-center justify-center">
|
|
||||||
<div class="uppercase text-indigo-500 ml-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
|
|
||||||
{fontB?.name ?? 'typeface_01'}
|
|
||||||
</div>
|
|
||||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
|
||||||
<div class="uppercase text-neutral-950 mr-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
|
|
||||||
{fontA?.name ?? 'typeface_02'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class={cn(className, 'flex flex-col gap-2 h-[60vh]')}>
|
|
||||||
<Label class="mb-2" text="Available Fonts" align="center" />
|
|
||||||
|
|
||||||
<div class="h-full overflow-hidden">
|
|
||||||
<FontList />
|
|
||||||
</div>
|
|
||||||
<Label class="mb-2" text="Typography Controls" align="center" />
|
|
||||||
|
|
||||||
<div class="mx-4 flex-shrink-0">
|
|
||||||
<TypographyControls />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Drawer>
|
|
||||||
{:else}
|
|
||||||
<SidebarMenu
|
|
||||||
class={cn(
|
|
||||||
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-0 sm:gap-0 pointer-events-auto overflow-hidden',
|
|
||||||
'relative h-full transition-all duration-700 ease-out',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
bind:visible
|
|
||||||
bind:wrapper={menuWrapper}
|
|
||||||
onClickOutside={handleToggle}
|
|
||||||
>
|
|
||||||
{#snippet action()}
|
|
||||||
<!-- Always-visible mode switch -->
|
|
||||||
<div class={cn('absolute top-2 left-0 z-50', visible && 'w-full')}>
|
|
||||||
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
<Label class="mb-2 mr-4 lg:mr-6" text="Available Fonts" align="left" />
|
|
||||||
|
|
||||||
<div class="mb-2 h-2/3 overflow-hidden">
|
|
||||||
<FontList />
|
|
||||||
</div>
|
|
||||||
<Label class="mb-2 mr-4 lg:mr-6" text="Typography Controls" align="left" />
|
|
||||||
|
|
||||||
<div class="mr-4 sm:mr-6">
|
|
||||||
<TypographyControls />
|
|
||||||
</div>
|
|
||||||
</SidebarMenu>
|
|
||||||
{/if}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontList
|
|
||||||
A scrollable list of fonts with dual selection buttons for fontA and fontB.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
FontVirtualList,
|
|
||||||
type UnifiedFont,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { draw } from 'svelte/transition';
|
|
||||||
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
|
||||||
const typography = $derived(comparisonStore.typography);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a font as fontA (right slot - compare_to)
|
|
||||||
*/
|
|
||||||
function selectFontA(font: UnifiedFont) {
|
|
||||||
comparisonStore.fontA = font;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a font as fontB (left slot - compare_from)
|
|
||||||
*/
|
|
||||||
function selectFontB(font: UnifiedFont) {
|
|
||||||
comparisonStore.fontB = font;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a font is selected as fontA
|
|
||||||
*/
|
|
||||||
function isFontA(font: UnifiedFont): boolean {
|
|
||||||
return fontA?.id === font.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a font is selected as fontB
|
|
||||||
*/
|
|
||||||
function isFontB(font: UnifiedFont): boolean {
|
|
||||||
return fontB?.id === font.id;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet rightBrackets(className?: string)}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={cn(
|
|
||||||
'lucide lucide-focus-icon lucide-focus right-0 top-0 absolute',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
|
||||||
d="M17 3h2a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={cn(
|
|
||||||
'lucide lucide-focus-icon lucide-focus right-0 bottom-0 absolute',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
|
||||||
d="M21 17v2a2 2 0 0 1-2 2h-2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet leftBrackets(className?: string)}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={cn(
|
|
||||||
'lucide lucide-focus-icon lucide-focus left-0 top-0 absolute',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
|
||||||
d="M3 7V5a2 2 0 0 1 2-2h2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={cn(
|
|
||||||
'lucide lucide-focus-icon lucide-focus left-0 bottom-0 absolute',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
|
||||||
d="M7 21H5a2 2 0 0 1-2-2v-2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet brackets(
|
|
||||||
renderLeft?: boolean,
|
|
||||||
renderRight?: boolean,
|
|
||||||
className?: string,
|
|
||||||
)}
|
|
||||||
{#if renderLeft}
|
|
||||||
{@render leftBrackets(className)}
|
|
||||||
{/if}
|
|
||||||
{#if renderRight}
|
|
||||||
{@render rightBrackets(className)}
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<div class="flex flex-col h-full min-h-0 bg-transparent">
|
|
||||||
<div class="flex-1 min-h-0">
|
|
||||||
<FontVirtualList
|
|
||||||
weight={typography.weight}
|
|
||||||
itemHeight={36}
|
|
||||||
class="bg-transparent h-full"
|
|
||||||
>
|
|
||||||
{#snippet children({ item: font })}
|
|
||||||
{@const isSelectedA = isFontA(font)}
|
|
||||||
{@const isSelectedB = isFontB(font)}
|
|
||||||
{@const isEither = isSelectedA || isSelectedB}
|
|
||||||
{@const isBoth = isSelectedA && isSelectedB}
|
|
||||||
{@const handleSelectFontA = () => selectFontA(font)}
|
|
||||||
{@const handleSelectFontB = () => selectFontB(font)}
|
|
||||||
|
|
||||||
<div class="group relative flex w-auto h-[36px] border-b border-black/[0.03] overflow-hidden sm:mr-4 lg:mr-6">
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-all duration-500 cubic-bezier-out',
|
|
||||||
isSelectedB && !isBoth && '-translate-x-1/4',
|
|
||||||
isSelectedA && !isBoth && 'translate-x-1/4',
|
|
||||||
isBoth && 'translate-x-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div class="relative flex items-center px-6">
|
|
||||||
<span
|
|
||||||
class={cn(
|
|
||||||
'text-[0.625rem] sm:text-[0.75rem] tracking-tighter select-none transition-all duration-300',
|
|
||||||
isEither
|
|
||||||
? 'opacity-100 font-bold'
|
|
||||||
: 'opacity-30 group-hover:opacity-100',
|
|
||||||
isSelectedB && 'text-indigo-500',
|
|
||||||
isSelectedA && 'text-normal-950',
|
|
||||||
isBoth
|
|
||||||
&& 'bg-[linear-gradient(to_right,theme(colors.indigo.500)_50%,theme(colors.neutral.950)_50%)] bg-clip-text text-transparent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
--- {font.name} ---
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={handleSelectFontB}
|
|
||||||
class="flex-1 relative flex items-center justify-between transition-all duration-200 cursor-pointer hover:bg-indigo-500/[0.03]"
|
|
||||||
>
|
|
||||||
{@render brackets(
|
|
||||||
isSelectedB,
|
|
||||||
isSelectedB && !isBoth,
|
|
||||||
'stroke-1 size-7 stroke-indigo-600',
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={handleSelectFontA}
|
|
||||||
class="flex-1 relative flex items-center justify-end transition-all duration-200 cursor-pointer hover:bg-black/[0.02]"
|
|
||||||
>
|
|
||||||
{@render brackets(
|
|
||||||
isSelectedA && !isBoth,
|
|
||||||
isSelectedA,
|
|
||||||
'stroke-1 size-7 stroke-normal-950',
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</FontVirtualList>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: SliderLine
|
|
||||||
Visual representation of the comparison slider line.
|
|
||||||
1px red vertical rule with square handles at top and bottom.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Position of the slider as a percentage (0–100) */
|
|
||||||
sliderPos: number;
|
|
||||||
/** Whether the slider is being dragged */
|
|
||||||
isDragging: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { sliderPos, isDragging }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute top-0 bottom-0 z-50 pointer-events-none w-px bg-brand flex flex-col justify-between"
|
|
||||||
style:left="{sliderPos}%"
|
|
||||||
style:will-change={isDragging ? 'left' : 'auto'}
|
|
||||||
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
|
|
||||||
out:fade={{ duration: 150, easing: cubicOut }}
|
|
||||||
>
|
|
||||||
<!-- Top handle -->
|
|
||||||
<div class={cn(
|
|
||||||
'w-5 h-5 md:w-6 md:h-6',
|
|
||||||
'-ml-2.5 md:-ml-3',
|
|
||||||
'mt-2 md:mt-4',
|
|
||||||
'bg-brand text-white',
|
|
||||||
'flex items-center justify-center',
|
|
||||||
'rounded-none shadow-md',
|
|
||||||
'transition-transform duration-150',
|
|
||||||
isDragging ? 'scale-110' : 'scale-100',
|
|
||||||
)}>
|
|
||||||
<div class="w-0.5 h-2 md:h-3 bg-white/80"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom handle -->
|
|
||||||
<div class={cn(
|
|
||||||
'w-5 h-5 md:w-6 md:h-6',
|
|
||||||
'-ml-2.5 md:-ml-3',
|
|
||||||
'mb-2 md:mb-4',
|
|
||||||
'bg-brand text-white',
|
|
||||||
'flex items-center justify-center',
|
|
||||||
'rounded-none shadow-md',
|
|
||||||
'transition-transform duration-150',
|
|
||||||
isDragging ? 'scale-110' : 'scale-100',
|
|
||||||
)}>
|
|
||||||
<div class="w-0.5 h-2 md:h-3 bg-white/80"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: ToggleMenuButton
|
|
||||||
Toggles menu sidebar, displays selected fonts names
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { draw } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isActive?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { isActive = $bindable(false), onClick }: Props = $props();
|
|
||||||
|
|
||||||
// Handle click and toggle
|
|
||||||
const toggle = () => {
|
|
||||||
onClick?.();
|
|
||||||
isActive = !isActive;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet icon(className?: string)}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={cn(
|
|
||||||
'lucide lucide-circle-arrow-right-icon lucide-circle-arrow-right',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
{#if isActive}
|
|
||||||
<path
|
|
||||||
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
|
|
||||||
d="m15 9-6 6"
|
|
||||||
/><path
|
|
||||||
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
|
|
||||||
d="m9 9 6 6"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<path
|
|
||||||
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
|
|
||||||
d="m12 16 4-4-4-4"
|
|
||||||
/><path
|
|
||||||
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
|
|
||||||
d="M8 12h8"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={toggle}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
class={cn(
|
|
||||||
'group relative flex items-center justify-center gap-2 sm:gap-3 px-4 sm:px-6 py-2',
|
|
||||||
'cursor-pointer select-none overflow-hidden',
|
|
||||||
'transition-transform duration-150 active:scale-98',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{@render icon(
|
|
||||||
cn(
|
|
||||||
'size-4 stroke-[1.5] stroke-gray-500',
|
|
||||||
!isActive && 'rotate-90 sm:rotate-0',
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
|
||||||
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-indigo-500 text-right whitespace-nowrap">
|
|
||||||
{fontB?.name}
|
|
||||||
</div>
|
|
||||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
|
||||||
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-neural-950 text-left whitespace-nowrap">
|
|
||||||
{fontA?.name}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: TypographyControls
|
|
||||||
Controls for text input and typography settings (size, weight, height).
|
|
||||||
Simplified version for static positioning in settings mode.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
ComboControlV2,
|
|
||||||
Input,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
|
||||||
|
|
||||||
const typography = $derived(comparisonStore.typography);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Text input -->
|
|
||||||
<Input
|
|
||||||
bind:value={comparisonStore.text}
|
|
||||||
size="sm"
|
|
||||||
label="Text"
|
|
||||||
placeholder="The quick brown fox..."
|
|
||||||
class="w-full h-10 px-3 py-2 sm:mr-4 mb-8 sm:mb-4 rounded-lg border border-border-muted bg-background-60 backdrop-blur-sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Typography controls -->
|
|
||||||
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
|
||||||
<div class="flex flex-col mt-1.5">
|
|
||||||
<ComboControlV2
|
|
||||||
control={typography.weightControl}
|
|
||||||
orientation="horizontal"
|
|
||||||
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
|
|
||||||
label="font weight"
|
|
||||||
showScale={false}
|
|
||||||
reduced
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ComboControlV2
|
|
||||||
control={typography.sizeControl}
|
|
||||||
orientation="horizontal"
|
|
||||||
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
|
|
||||||
label="font size"
|
|
||||||
showScale={false}
|
|
||||||
reduced
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ComboControlV2
|
|
||||||
control={typography.heightControl}
|
|
||||||
orientation="horizontal"
|
|
||||||
class="sm:py-0 sm:px-0"
|
|
||||||
label="line height"
|
|
||||||
showScale={false}
|
|
||||||
reduced
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
|
|
||||||
|
|
||||||
export { ComparisonSlider };
|
|
||||||
Reference in New Issue
Block a user